diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 28324fc147e..5dd80301845 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -36,6 +36,8 @@ import org.apache.cloudstack.gpu.GpuCard; import org.apache.cloudstack.gpu.GpuDevice; import org.apache.cloudstack.gpu.VgpuProfile; import org.apache.cloudstack.ha.HAConfig; +import org.apache.cloudstack.kms.HSMProfile; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.network.BgpPeer; import org.apache.cloudstack.network.Ipv4GuestSubnetNetworkMap; import org.apache.cloudstack.quota.QuotaTariff; @@ -274,12 +276,17 @@ public class EventTypes { // KMS (Key Management Service) events public static final String EVENT_KMS_KEY_WRAP = "KMS.KEY.WRAP"; public static final String EVENT_KMS_KEY_UNWRAP = "KMS.KEY.UNWRAP"; - public static final String EVENT_KMS_KEK_CREATE = "KMS.KEK.CREATE"; - public static final String EVENT_KMS_KEK_ROTATE = "KMS.KEK.ROTATE"; - public static final String EVENT_KMS_KEK_DELETE = "KMS.KEK.DELETE"; - public static final String EVENT_KMS_HEALTH_CHECK = "KMS.HEALTH.CHECK"; + public static final String EVENT_KMS_KEY_CREATE = "KMS.KEY.CREATE"; + public static final String EVENT_KMS_KEY_UPDATE = "KMS.KEY.UPDATE"; + public static final String EVENT_KMS_KEY_ROTATE = "KMS.KEY.ROTATE"; + public static final String EVENT_KMS_KEY_DELETE = "KMS.KEY.DELETE"; public static final String EVENT_VOLUME_MIGRATE_TO_KMS = "VOLUME.MIGRATE.TO.KMS"; + // HSM Profile events + public static final String EVENT_HSM_PROFILE_CREATE = "HSM.PROFILE.CREATE"; + public static final String EVENT_HSM_PROFILE_UPDATE = "HSM.PROFILE.UPDATE"; + public static final String EVENT_HSM_PROFILE_DELETE = "HSM.PROFILE.DELETE"; + // Account events public static final String EVENT_ACCOUNT_ENABLE = "ACCOUNT.ENABLE"; public static final String EVENT_ACCOUNT_DISABLE = "ACCOUNT.DISABLE"; @@ -1024,6 +1031,19 @@ public class EventTypes { entityEventDetails.put(EVENT_VOLUME_RECOVER, Volume.class); entityEventDetails.put(EVENT_VOLUME_CHANGE_DISK_OFFERING, Volume.class); + // KMS Key Events + entityEventDetails.put(EVENT_KMS_KEY_CREATE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_UPDATE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_UNWRAP, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_WRAP, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_DELETE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_ROTATE, KMSKey.class); + + // HSM Profile Events + entityEventDetails.put(EVENT_HSM_PROFILE_CREATE, HSMProfile.class); + entityEventDetails.put(EVENT_HSM_PROFILE_UPDATE, HSMProfile.class); + entityEventDetails.put(EVENT_HSM_PROFILE_DELETE, HSMProfile.class); + // Domains entityEventDetails.put(EVENT_DOMAIN_CREATE, Domain.class); entityEventDetails.put(EVENT_DOMAIN_DELETE, Domain.class); diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java index b8315e6435a..91e702df33d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -90,7 +90,8 @@ public enum ApiCommandResourceType { SharedFS(org.apache.cloudstack.storage.sharedfs.SharedFS.class), Extension(org.apache.cloudstack.extension.Extension.class), ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class), - KmsKey(org.apache.cloudstack.kms.KMSKey.class); + KmsKey(org.apache.cloudstack.kms.KMSKey.class), + HsmProfile(org.apache.cloudstack.kms.HSMProfile.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index cb359c53803..de380bab086 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -870,6 +870,7 @@ public class ApiConstants { public static final String HSM_PROFILE = "hsmprofile"; public static final String HSM_PROFILE_ID = "hsmprofileid"; public static final String PURPOSE = "purpose"; + public static final String KMS_KEY = "kmskey"; public static final String KMS_KEY_ID = "kmskeyid"; public static final String KMS_KEY_VERSION = "kmskeyversion"; public static final String KEK_LABEL = "keklabel"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index a9c9cc7850f..2e3832f29b3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -23,6 +23,7 @@ import java.util.Map; import java.util.Set; import org.apache.cloudstack.api.response.ConsoleSessionResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.consoleproxy.ConsoleSession; import org.apache.cloudstack.acl.apikeypair.ApiKeyPair; import org.apache.cloudstack.acl.apikeypair.ApiKeyPairPermission; @@ -76,8 +77,6 @@ import org.apache.cloudstack.api.response.HypervisorGuestOsNamesResponse; import org.apache.cloudstack.api.response.IPAddressResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; import org.apache.cloudstack.api.response.InstanceGroupResponse; -import org.apache.cloudstack.api.response.KMSKeyResponse; -import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.api.response.InternalLoadBalancerElementResponse; import org.apache.cloudstack.api.response.IpForwardingRuleResponse; import org.apache.cloudstack.api.response.IpQuarantineResponse; @@ -158,6 +157,7 @@ import org.apache.cloudstack.direct.download.DirectDownloadCertificate; import org.apache.cloudstack.direct.download.DirectDownloadCertificateHostMap; import org.apache.cloudstack.direct.download.DirectDownloadManager; import org.apache.cloudstack.gui.theme.GuiThemeJoin; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.management.ManagementServerHost; import org.apache.cloudstack.network.lb.ApplicationLoadBalancerRule; import org.apache.cloudstack.region.PortableIp; @@ -283,7 +283,8 @@ public interface ResponseGenerator { List createUserVmResponse(ResponseView view, String objectName, UserVm... userVms); - List createUserVmResponse(ResponseView view, String objectName, EnumSet details, UserVm... userVms); + List createUserVmResponse(ResponseView view, String objectName, EnumSet details, + UserVm... userVms); SystemVmResponse createSystemVmResponse(VirtualMachine systemVM); @@ -309,11 +310,13 @@ public interface ResponseGenerator { LoadBalancerResponse createLoadBalancerResponse(LoadBalancer loadBalancer); - LBStickinessResponse createLBStickinessPolicyResponse(List stickinessPolicies, LoadBalancer lb); + LBStickinessResponse createLBStickinessPolicyResponse(List stickinessPolicies, + LoadBalancer lb); LBStickinessResponse createLBStickinessPolicyResponse(StickinessPolicy stickinessPolicy, LoadBalancer lb); - LBHealthCheckResponse createLBHealthCheckPolicyResponse(List healthcheckPolicies, LoadBalancer lb); + LBHealthCheckResponse createLBHealthCheckPolicyResponse(List healthcheckPolicies, + LoadBalancer lb); LBHealthCheckResponse createLBHealthCheckPolicyResponse(HealthCheckPolicy healthcheckPolicy, LoadBalancer lb); @@ -321,7 +324,8 @@ public interface ResponseGenerator { PodResponse createMinimalPodResponse(Pod pod); - ZoneResponse createZoneResponse(ResponseView view, DataCenter dataCenter, Boolean showCapacities, Boolean showResourceIcon); + ZoneResponse createZoneResponse(ResponseView view, DataCenter dataCenter, Boolean showCapacities, + Boolean showResourceIcon); DataCenterGuestIpv6PrefixResponse createDataCenterGuestIpv6PrefixResponse(DataCenterGuestIpv6Prefix prefix); @@ -361,7 +365,8 @@ public interface ResponseGenerator { List createTemplateResponses(ResponseView view, long templateId, Long zoneId, boolean readyOnly); - List createTemplateResponses(ResponseView view, long templateId, Long snapshotId, Long volumeId, boolean readyOnly); + List createTemplateResponses(ResponseView view, long templateId, Long snapshotId, Long volumeId, + boolean readyOnly); SecurityGroupResponse createSecurityGroupResponseFromSecurityGroupRule(List securityRules); @@ -380,14 +385,15 @@ public interface ResponseGenerator { TemplateResponse createTemplateUpdateResponse(ResponseView view, VirtualMachineTemplate result); List createTemplateResponses(ResponseView view, VirtualMachineTemplate result, - Long zoneId, boolean readyOnly); + Long zoneId, boolean readyOnly); List createTemplateResponses(ResponseView view, VirtualMachineTemplate result, - List zoneIds, boolean readyOnly); + List zoneIds, boolean readyOnly); List createCapacityResponse(List result, DecimalFormat format); - TemplatePermissionsResponse createTemplatePermissionsResponse(ResponseView view, List accountNames, Long id); + TemplatePermissionsResponse createTemplatePermissionsResponse(ResponseView view, List accountNames, + Long id); AsyncJobResponse queryJobResult(QueryAsyncJobResultCmd cmd); @@ -401,7 +407,8 @@ public interface ResponseGenerator { Long getSecurityGroupId(String groupName, long accountId); - List createIsoResponses(ResponseView view, VirtualMachineTemplate iso, Long zoneId, boolean readyOnly); + List createIsoResponses(ResponseView view, VirtualMachineTemplate iso, Long zoneId, + boolean readyOnly); ProjectResponse createProjectResponse(Project project); @@ -502,13 +509,15 @@ public interface ResponseGenerator { GuestOsMappingResponse createGuestOSMappingResponse(GuestOSHypervisor osHypervisor); - HypervisorGuestOsNamesResponse createHypervisorGuestOSNamesResponse(List> hypervisorGuestOsNames); + HypervisorGuestOsNamesResponse createHypervisorGuestOSNamesResponse( + List> hypervisorGuestOsNames); SnapshotScheduleResponse createSnapshotScheduleResponse(SnapshotSchedule sched); UsageRecordResponse createUsageResponse(Usage usageRecord); - UsageRecordResponse createUsageResponse(Usage usageRecord, Map> resourceTagResponseMap, boolean oldFormat); + UsageRecordResponse createUsageResponse(Usage usageRecord, + Map> resourceTagResponseMap, boolean oldFormat); public Map> getUsageResourceTags(); @@ -520,7 +529,8 @@ public interface ResponseGenerator { public NicResponse createNicResponse(Nic result); - ApplicationLoadBalancerResponse createLoadBalancerContainerReponse(ApplicationLoadBalancerRule lb, Map lbInstances); + ApplicationLoadBalancerResponse createLoadBalancerContainerReponse(ApplicationLoadBalancerRule lb, + Map lbInstances); AffinityGroupResponse createAffinityGroupResponse(AffinityGroup group); @@ -546,9 +556,12 @@ public interface ResponseGenerator { ManagementServerResponse createManagementResponse(ManagementServerHost mgmt); - List createHealthCheckResponse(VirtualMachine router, List healthCheckResults); + List createHealthCheckResponse(VirtualMachine router, + List healthCheckResults); - RollingMaintenanceResponse createRollingMaintenanceResponse(Boolean success, String details, List hostsUpdated, List hostsSkipped); + RollingMaintenanceResponse createRollingMaintenanceResponse(Boolean success, String details, + List hostsUpdated, + List hostsSkipped); ResourceIconResponse createResourceIconResponse(ResourceIcon resourceIcon); @@ -558,11 +571,14 @@ public interface ResponseGenerator { DirectDownloadCertificateResponse createDirectDownloadCertificateResponse(DirectDownloadCertificate certificate); - List createDirectDownloadCertificateHostMapResponse(List hostMappings); + List createDirectDownloadCertificateHostMapResponse( + List hostMappings); - DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateHostStatusResponse(DirectDownloadManager.HostCertificateStatus status); + DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateHostStatusResponse( + DirectDownloadManager.HostCertificateStatus status); - DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateProvisionResponse(Long certificateId, Long hostId, Pair result); + DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateProvisionResponse(Long certificateId, + Long hostId, Pair result); FirewallResponse createIpv6FirewallRuleResponse(FirewallRule acl); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java index 4d76abf4155..eef4ef9af26 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java @@ -17,7 +17,6 @@ package org.apache.cloudstack.api.command.admin.kms; import com.cloud.dc.DataCenter; -import com.cloud.user.Account; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; @@ -29,11 +28,15 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.AsyncJobResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.VolumeResponse; import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.kms.KMSManager; import javax.inject.Inject; +import java.util.List; @APICommand(name = "migrateVolumesToKMS", description = "Migrates passphrase-based volumes to KMS (admin only)", @@ -48,12 +51,7 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.ZONE_ID, - required = true, type = CommandType.UUID, entityType = ZoneResponse.class, description = "Zone ID") @@ -70,17 +68,20 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { description = "Domain ID") private Long domainId; - @Parameter(name = ApiConstants.ID, + @Parameter(name = ApiConstants.VOLUME_IDS, + type = CommandType.LIST, + collectionType = CommandType.UUID, + entityType = VolumeResponse.class, + description = "List of volume IDs to migrate") + private List volumeIds; + + @Parameter(name = ApiConstants.KMS_KEY_ID, required = true, type = CommandType.UUID, entityType = KMSKeyResponse.class, description = "KMS Key ID to use for migrating volumes") private Long kmsKeyId; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// - public Long getZoneId() { return zoneId; } @@ -93,14 +94,14 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { return domainId; } + public List getVolumeIds() { + return volumeIds; + } + public Long getKmsKeyId() { return kmsKeyId; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// - @Override public void execute() { try { @@ -118,7 +119,11 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { @Override public long getEntityOwnerId() { - return Account.ACCOUNT_ID_SYSTEM; + KMSKey key = _entityMgr.findById(KMSKey.class, kmsKeyId); + if (key != null) { + return key.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java index 5c2b53744b8..c48c1343fa0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java @@ -20,7 +20,6 @@ package org.apache.cloudstack.api.command.user.kms; import com.cloud.exception.ResourceAllocationException; -import com.cloud.user.Account; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; @@ -33,6 +32,7 @@ import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; @@ -64,9 +64,8 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { private String description; @Parameter(name = ApiConstants.PURPOSE, - required = true, type = CommandType.STRING, - description = "Purpose of the key: volume, tls") + description = "Purpose of the key: volume, tls. (default: volume)") private String purpose; @Parameter(name = ApiConstants.ZONE_ID, @@ -87,6 +86,12 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { description = "Domain ID (for creating keys for child accounts - requires domain admin or admin)") private Long domainId; + @Parameter(name = ApiConstants.PROJECT_ID, + type = CommandType.UUID, + entityType = ProjectResponse.class, + description = "ID of the project to create the KMS key for") + private Long projectId; + @Parameter(name = ApiConstants.KEY_BITS, type = CommandType.INTEGER, description = "Key size in bits: 128, 192, or 256 (default: 256)") @@ -108,7 +113,7 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { } public String getPurpose() { - return purpose; + return purpose == null ? "volume" : purpose; } public Long getZoneId() { @@ -123,8 +128,12 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { return domainId; } + public Long getProjectId() { + return projectId; + } + public Integer getKeyBits() { - return keyBits != null ? keyBits : 256; // Default to 256 bits + return keyBits != null ? keyBits : 256; } public Long getHsmProfileId() { @@ -145,11 +154,11 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { @Override public long getEntityOwnerId() { - Account caller = CallContext.current().getCallingAccount(); - if (accountName != null || domainId != null) { - return _accountService.finalyzeAccountId(accountName, domainId, null, true); + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId != null) { + return accountId; } - return caller.getId(); + return CallContext.current().getCallingAccount().getId(); } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java index 10e982709b6..9963677c2f2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java @@ -33,6 +33,7 @@ import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.kms.KMSManager; import javax.inject.Inject; @@ -56,10 +57,6 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { description = "The UUID of the KMS key to delete") private Long id; - public Long getId() { - return id; - } - @Override public void execute() { try { @@ -74,12 +71,21 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { @Override public long getEntityOwnerId() { + KMSKey key = _entityMgr.findById(KMSKey.class, id); + if (key != null) { + return key.getAccountId(); + } return CallContext.current().getCallingAccount().getId(); } + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + @Override public String getEventType() { - return EventTypes.EVENT_KMS_KEK_DELETE; + return EventTypes.EVENT_KMS_KEY_DELETE; } @Override @@ -87,8 +93,7 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return "deleting KMS key: " + getId(); } - @Override - public ApiCommandResourceType getApiResourceType() { - return ApiCommandResourceType.KmsKey; + public Long getId() { + return id; } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java index 1bb2e38853a..3856cc9fed5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java @@ -22,10 +22,11 @@ package org.apache.cloudstack.api.command.user.kms; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.api.BaseListAccountResourcesCmd; +import org.apache.cloudstack.api.BaseListProjectAndAccountResourcesCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.ZoneResponse; @@ -41,7 +42,7 @@ import javax.inject.Inject; authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) -public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserCmd { +public class ListKMSKeysCmd extends BaseListProjectAndAccountResourcesCmd implements UserCmd { private static final String s_name = "listkmskeysresponse"; @Inject @@ -64,10 +65,16 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC description = "Filter by zone ID") private Long zoneId; - @Parameter(name = ApiConstants.STATE, - type = CommandType.STRING, - description = "Filter by state: Enabled, Disabled") - private String state; + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "Filter by enabled status") + private Boolean enabled; + + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + description = "Filter by HSM profile ID") + private Long hsmProfileId; public Long getId() { return id; @@ -81,13 +88,13 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC return zoneId; } - public String getState() { - return state; + public Boolean getEnabled() { + return enabled; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// + public Long getHsmProfileId() { + return hsmProfileId; + } @Override public void execute() { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java similarity index 82% rename from api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java rename to api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java index 5b6125703d5..a370a1d5d59 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java @@ -14,9 +14,8 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package org.apache.cloudstack.api.command.admin.kms; +package org.apache.cloudstack.api.command.user.kms; -import com.cloud.user.Account; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; @@ -29,6 +28,7 @@ import org.apache.cloudstack.api.response.AsyncJobResponse; import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.kms.KMSManager; @@ -36,10 +36,10 @@ import org.apache.cloudstack.kms.KMSManager; import javax.inject.Inject; @APICommand(name = "rotateKMSKey", - description = "Rotates KEK by creating new version and scheduling gradual re-encryption (admin only)", + description = "Rotates KEK by creating new version and scheduling gradual re-encryption", responseObject = AsyncJobResponse.class, since = "4.23.0", - authorized = {RoleType.Admin}, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class RotateKMSKeyCmd extends BaseAsyncCmd { @@ -61,10 +61,11 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { private Integer keyBits; @Parameter(name = ApiConstants.HSM_PROFILE_ID, - type = CommandType.UUID, - entityType = HSMProfileResponse.class, - description = "The target HSM profile ID for the new KEK version. If provided, migrates the key to this HSM.") - private String hsmProfile; + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + description = "The target HSM profile ID for the new KEK version. If provided, migrates the key to " + + "this HSM.") + private Long hsmProfileId; public Long getId() { return id; @@ -74,8 +75,8 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { return keyBits; } - public String getHsmProfile() { - return hsmProfile; + public Long getHsmProfileId() { + return hsmProfileId; } @Override @@ -98,12 +99,16 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { @Override public long getEntityOwnerId() { - return Account.ACCOUNT_ID_SYSTEM; + KMSKey key = _entityMgr.findById(KMSKey.class, id); + if (key != null) { + return key.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); } @Override public String getEventType() { - return com.cloud.event.EventTypes.EVENT_KMS_KEK_ROTATE; + return com.cloud.event.EventTypes.EVENT_KMS_KEY_ROTATE; } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java index d4d83a08cc7..4eba649ecb4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java @@ -37,7 +37,7 @@ import org.apache.cloudstack.kms.KMSManager; import javax.inject.Inject; @APICommand(name = "updateKMSKey", - description = "Updates KMS key name, description, or state", + description = "Updates KMS key name, description, or enabled status", responseObject = KMSKeyResponse.class, since = "4.23.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, @@ -65,14 +65,10 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { description = "New description for the key") private String description; - @Parameter(name = ApiConstants.STATE, - type = CommandType.STRING, - description = "New state: Enabled or Disabled") - private String state; - - public Long getId() { - return id; - } + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "whether the key should be enabled") + private Boolean enabled; public String getName() { return name; @@ -82,8 +78,8 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return description; } - public String getState() { - return state; + public Boolean getEnabled() { + return enabled; } @Override @@ -103,9 +99,14 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return CallContext.current().getCallingAccount().getId(); } + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + @Override public String getEventType() { - return EventTypes.EVENT_KMS_KEK_CREATE; // Reuse create event type for updates + return EventTypes.EVENT_KMS_KEY_UPDATE; } @Override @@ -113,8 +114,7 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return "updating KMS key: " + getId(); } - @Override - public ApiCommandResourceType getApiResourceType() { - return ApiCommandResourceType.KmsKey; + public Long getId() { + return id; } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java index eefed29e04e..9617a48b260 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java @@ -23,15 +23,16 @@ import com.cloud.exception.NetworkRuleConflictException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.utils.StringUtils; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; -import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; @@ -45,37 +46,47 @@ import java.util.HashMap; import java.util.Map; @APICommand(name = "addHSMProfile", description = "Adds a new HSM profile", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0") + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class AddHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, - description = "the name of the HSM profile") + description = "the name of the HSM profile") private String name; @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, - description = "the protocol of the HSM profile (PKCS11, KMIP, etc.). Default is 'pkcs11'") + description = "the protocol of the HSM profile (PKCS11, KMIP, etc.). Default is 'pkcs11'") private String protocol; @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, - description = "the zone ID where the HSM profile is available. If null, global scope (for admin only)") + description = "the zone ID where the HSM profile is available. If null, global scope (for admin only)") private Long zoneId; @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, - description = "the domain ID where the HSM profile is available") + description = "the domain ID where the HSM profile is available") private Long domainId; - @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, - description = "the account ID of the HSM profile owner. If null, admin-provided (available to all " - + "accounts)") - private Long accountId; + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, + description = "the account name of the HSM profile owner. Must be used with domainId.") + private String accountName; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "the ID of the project to add the HSM profile for") + private Long projectId; + + @Parameter(name = "system", type = CommandType.BOOLEAN, + description = "whether this is a system HSM profile available to all users globally (root admin only). " + + "Default is false") + private Boolean system; @Parameter(name = ApiConstants.VENDOR_NAME, type = CommandType.STRING, description = "the vendor name of the HSM") private String vendorName; - @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "HSM configuration details (protocol specific)") + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "HSM configuration details (protocol specific)") private Map details; public String getName() { @@ -97,8 +108,16 @@ public class AddHSMProfileCmd extends BaseCmd { return domainId; } - public Long getAccountId() { - return accountId; + public String getAccountName() { + return accountName; + } + + public Long getProjectId() { + return projectId; + } + + public Boolean isSystem() { + return system != null && system; } public String getVendorName() { @@ -111,8 +130,8 @@ public class AddHSMProfileCmd extends BaseCmd { Collection props = details.values(); for (Object prop : props) { HashMap detail = (HashMap) prop; - for (Map.Entry entry: detail.entrySet()) { - detailsMap.put(entry.getKey(),entry.getValue()); + for (Map.Entry entry : detail.entrySet()) { + detailsMap.put(entry.getKey(), entry.getValue()); } } } @@ -123,12 +142,6 @@ public class AddHSMProfileCmd extends BaseCmd { public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { - // Default to caller account if not admin and accountId not specified - // But wait, the plan says: "No accountId parameter means account_id = NULL (admin-provided)" - // However, regular users can add their own profiles. - // So if caller is normal user, accountId should be forced to their account. - - // Logic handled in KMSManagerImpl HSMProfile profile = kmsManager.addHSMProfile(this); HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); response.setResponseName(getCommandName()); @@ -140,6 +153,7 @@ public class AddHSMProfileCmd extends BaseCmd { @Override public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); if (accountId != null) { return accountId; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java index da264e92deb..8fc0a47c3ad 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java @@ -17,8 +17,12 @@ package org.apache.cloudstack.api.command.user.kms.hsm; -import javax.inject.Inject; - +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; @@ -32,20 +36,18 @@ import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.HSMProfile; import org.apache.cloudstack.kms.KMSManager; -import com.cloud.exception.ConcurrentOperationException; -import com.cloud.exception.InsufficientCapacityException; -import com.cloud.exception.NetworkRuleConflictException; -import com.cloud.exception.ResourceAllocationException; -import com.cloud.exception.ResourceUnavailableException; +import javax.inject.Inject; @APICommand(name = "deleteHSMProfile", description = "Deletes an HSM profile", responseObject = SuccessResponse.class, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0") + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class DeleteHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, description = "the ID of the HSM profile") + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, + description = "the ID of the HSM profile") private Long id; public Long getId() { @@ -53,7 +55,8 @@ public class DeleteHSMProfileCmd extends BaseCmd { } @Override - public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { boolean result = kmsManager.deleteHSMProfile(this); if (result) { @@ -70,7 +73,7 @@ public class DeleteHSMProfileCmd extends BaseCmd { @Override public long getEntityOwnerId() { HSMProfile profile = _entityMgr.findById(HSMProfile.class, id); - if (profile != null && profile.getAccountId() != null) { + if (profile != null && profile.getAccountId() > 0) { return profile.getAccountId(); } return CallContext.current().getCallingAccount().getId(); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java index 145c65b9922..d42d701c2f2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java @@ -17,32 +17,32 @@ package org.apache.cloudstack.api.command.user.kms.hsm; -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; - +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.BaseListProjectAndAccountResourcesCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.ZoneResponse; -import org.apache.cloudstack.kms.HSMProfile; import org.apache.cloudstack.kms.KMSManager; +import javax.inject.Inject; + @APICommand(name = "listHSMProfiles", description = "Lists HSM profiles", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "4.23.0") -public class ListHSMProfilesCmd extends BaseListCmd { + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListHSMProfilesCmd extends BaseListProjectAndAccountResourcesCmd { @Inject private KMSManager kmsManager; - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, description = "the HSM profile ID") + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, + description = "the HSM profile ID") private Long id; - @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, description = "the zone ID") + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the zone ID") private Long zoneId; @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, description = "the protocol of the HSM profile") @@ -51,6 +51,11 @@ public class ListHSMProfilesCmd extends BaseListCmd { @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "list only enabled profiles") private Boolean enabled; + @Parameter(name = ApiConstants.IS_SYSTEM, + type = CommandType.BOOLEAN, + description = "when true, non-admin users see only system (global) profiles") + private Boolean isSystem; + public Long getId() { return id; } @@ -67,18 +72,13 @@ public class ListHSMProfilesCmd extends BaseListCmd { return enabled; } + public Boolean getIsSystem() { + return isSystem; + } + @Override public void execute() { - List profiles = kmsManager.listHSMProfiles(this); - ListResponse response = new ListResponse<>(); - List profileResponses = new ArrayList<>(); - - for (HSMProfile profile : profiles) { - HSMProfileResponse profileResponse = kmsManager.createHSMProfileResponse(profile); - profileResponses.add(profileResponse); - } - - response.setResponses(profileResponses); + ListResponse response = kmsManager.listHSMProfiles(this); response.setResponseName(getCommandName()); setResponseObject(response); } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java index 8b3f39b2c2d..36de6d447e1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java @@ -17,10 +17,12 @@ package org.apache.cloudstack.api.command.user.kms.hsm; -import java.util.Map; - -import javax.inject.Inject; - +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; @@ -33,31 +35,28 @@ import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.HSMProfile; import org.apache.cloudstack.kms.KMSManager; -import com.cloud.exception.ConcurrentOperationException; -import com.cloud.exception.InsufficientCapacityException; -import com.cloud.exception.NetworkRuleConflictException; -import com.cloud.exception.ResourceAllocationException; -import com.cloud.exception.ResourceUnavailableException; +import javax.inject.Inject; -@APICommand(name = "updateHSMProfile", description = "Updates an HSM profile", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0") +@APICommand(name = "updateHSMProfile", description = "Updates an HSM profile", + responseObject = HSMProfileResponse.class, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class UpdateHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, description = "the ID of the HSM profile") + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, + description = "the ID of the HSM profile") private Long id; @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "the name of the HSM profile") private String name; - @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "whether the HSM profile is enabled") + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, + description = "whether the HSM profile is enabled") private Boolean enabled; - @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "HSM configuration details to update (protocol specific)") - private Map details; - public Long getId() { return id; } @@ -70,12 +69,9 @@ public class UpdateHSMProfileCmd extends BaseCmd { return enabled; } - public Map getDetails() { - return details; - } - @Override - public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { HSMProfile profile = kmsManager.updateHSMProfile(this); HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); @@ -89,7 +85,7 @@ public class UpdateHSMProfileCmd extends BaseCmd { @Override public long getEntityOwnerId() { HSMProfile profile = _entityMgr.findById(HSMProfile.class, id); - if (profile != null && profile.getAccountId() != null) { + if (profile != null && profile.getAccountId() > 0) { return profile.getAccountId(); } return CallContext.current().getCallingAccount().getId(); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java index a4cd299dae9..88f87e941e7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java @@ -29,6 +29,7 @@ import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.PodResponse; import org.apache.cloudstack.api.response.ServiceOfferingResponse; @@ -90,6 +91,9 @@ public class ListVolumesCmd extends BaseListRetrieveOnlyResourceCountCmd impleme @Parameter(name = ApiConstants.DISK_OFFERING_ID, type = CommandType.UUID, entityType = DiskOfferingResponse.class, description = "List volumes by disk offering", since = "4.4") private Long diskOfferingId; + @Parameter(name = ApiConstants.KMS_KEY_ID, type = CommandType.UUID, entityType = KMSKeyResponse.class, description = "List volumes by KMS Key", since = "4.23") + private Long kmsKeyId; + @Parameter(name = ApiConstants.DISPLAY_VOLUME, type = CommandType.BOOLEAN, description = "List resources by display flag; only ROOT admin is eligible to pass this parameter", since = "4.4", authorized = { RoleType.Admin}) private Boolean display; @@ -136,6 +140,10 @@ public class ListVolumesCmd extends BaseListRetrieveOnlyResourceCountCmd impleme return diskOfferingId; } + public Long getKmsKeyId() { + return kmsKeyId; + } + public String getType() { return type; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java index e528f969983..607d1f0c379 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java @@ -29,7 +29,7 @@ import com.cloud.serializer.Param; import com.google.gson.annotations.SerializedName; @EntityReference(value = HSMProfile.class) -public class HSMProfileResponse extends BaseResponse { +public class HSMProfileResponse extends BaseResponse implements ControlledViewEntityResponse { @SerializedName(ApiConstants.ID) @Param(description = "the ID of the HSM profile") private String id; @@ -58,6 +58,18 @@ public class HSMProfileResponse extends BaseResponse { @Param(description = "the domain name of the HSM profile owner") private String domainName; + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "the domain path of the HSM profile owner") + private String domainPath; + + @SerializedName(ApiConstants.PROJECT_ID) + @Param(description = "the project ID of the HSM profile owner") + private String projectId; + + @SerializedName(ApiConstants.PROJECT) + @Param(description = "the project name of the HSM profile owner") + private String projectName; + @SerializedName(ApiConstants.ZONE_ID) @Param(description = "the zone ID where the HSM profile is available") private String zoneId; @@ -78,6 +90,10 @@ public class HSMProfileResponse extends BaseResponse { @Param(description = "whether the HSM profile is enabled") private Boolean enabled; + @SerializedName("system") + @Param(description = "whether this is a system HSM profile available to all users globally") + private Boolean system; + @SerializedName(ApiConstants.CREATED) @Param(description = "the date the HSM profile was created") private Date created; @@ -102,18 +118,36 @@ public class HSMProfileResponse extends BaseResponse { this.accountId = accountId; } + @Override public void setAccountName(String accountName) { this.accountName = accountName; } + @Override public void setDomainId(String domainId) { this.domainId = domainId; } + @Override public void setDomainName(String domainName) { this.domainName = domainName; } + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + @Override + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + @Override + public void setProjectName(String projectName) { + this.projectName = projectName; + } + public void setZoneId(String zoneId) { this.zoneId = zoneId; } @@ -134,6 +168,10 @@ public class HSMProfileResponse extends BaseResponse { this.enabled = enabled; } + public void setSystem(Boolean system) { + this.system = system; + } + public void setCreated(Date created) { this.created = created; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java index e2ab7c48851..b9fdb1644ce 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java @@ -30,7 +30,7 @@ import org.apache.cloudstack.kms.KMSKey; import java.util.Date; @EntityReference(value = KMSKey.class) -public class KMSKeyResponse extends BaseResponse implements ControlledEntityResponse { +public class KMSKeyResponse extends BaseResponse implements ControlledViewEntityResponse { @SerializedName(ApiConstants.ID) @Param(description = "the UUID of the key") @@ -76,9 +76,13 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp @Param(description = "the zone name where the key is valid") private String zoneName; - @SerializedName(ApiConstants.PROVIDER) - @Param(description = "the KMS provider (database, pkcs11, etc.)") - private String provider; + @SerializedName(ApiConstants.HSM_PROFILE_ID) + @Param(description = "the zone ID where the key is valid") + private String hsmProfileId; + + @SerializedName(ApiConstants.HSM_PROFILE) + @Param(description = "the zone name where the key is valid") + private String hsmProfileName; @SerializedName(ApiConstants.ALGORITHM) @Param(description = "the encryption algorithm") @@ -88,20 +92,29 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp @Param(description = "the key size in bits") private Integer keyBits; - @SerializedName(ApiConstants.STATE) - @Param(description = "the state of the key (Enabled, Disabled, Deleted)") - private String state; + @SerializedName(ApiConstants.VERSION) + @Param(description = "the key size in bits") + private Integer version; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "whether the key is enabled") + private Boolean enabled; @SerializedName(ApiConstants.CREATED) @Param(description = "the creation timestamp") private Date created; - // KEK label is admin-only for security - @SerializedName(ApiConstants.KEK_LABEL) - @Param(description = "the provider-specific KEK label (admin only)", authorized = {RoleType.Admin}) - private String kekLabel; + @SerializedName(ApiConstants.PROJECT_ID) + @Param(description = "the project ID of the key") + private String projectId; - // Getters and Setters + @SerializedName(ApiConstants.PROJECT) + @Param(description = "the project name of the key") + private String projectName; + + @SerializedName(ApiConstants.KEK_LABEL) + @Param(description = "the provider-specific KEK label (admin only)", authorized = { RoleType.Admin }) + private String kekLabel; public String getId() { return id; @@ -146,12 +159,12 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp @Override public void setProjectId(String projectId) { - // KMS keys are not project-scoped + this.projectId = projectId; } @Override public void setProjectName(String projectName) { - // KMS keys are not project-scoped + this.projectName = projectName; } public String getAccountId() { @@ -205,12 +218,20 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp this.zoneName = zoneName; } - public String getProvider() { - return provider; + public String getHsmProfileId() { + return hsmProfileId; } - public void setProvider(String provider) { - this.provider = provider; + public void setHsmProfileId(String hsmProfileId) { + this.hsmProfileId = hsmProfileId; + } + + public String getHsmProfileName() { + return hsmProfileName; + } + + public void setHsmProfileName(String hsmProfileName) { + this.hsmProfileName = hsmProfileName; } public String getAlgorithm() { @@ -229,12 +250,20 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp this.keyBits = keyBits; } - public String getState() { - return state; + public Integer getVersion() { + return version; } - public void setState(String state) { - this.state = state; + public void setVersion(Integer version) { + this.version = version; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; } public Date getCreated() { diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java index 76a81b20249..fd0c8ca10f5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java @@ -309,6 +309,10 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co @Param(description = "the format of the disk encryption if applicable", since = "4.19.1") private String encryptionFormat; + @SerializedName(ApiConstants.KMS_KEY) + @Param(description = "KMS key id of the volume", since = "4.23.0") + private String kmsKey; + @SerializedName(ApiConstants.KMS_KEY_ID) @Param(description = "KMS key id of the volume", since = "4.23.0") private String kmsKeyId; @@ -880,6 +884,14 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co this.encryptionFormat = encryptionFormat; } + public String getKmsKey() { + return kmsKey; + } + + public void setKmsKey(String kmsKey) { + this.kmsKey = kmsKey; + } + public String getKmsKeyId() { return kmsKeyId; } diff --git a/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java b/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java index 68071af4a98..97a38f16ba9 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java +++ b/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java @@ -17,19 +17,20 @@ package org.apache.cloudstack.kms; +import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; import java.util.Date; -public interface HSMProfile extends Identity, InternalIdentity { +public interface HSMProfile extends Identity, InternalIdentity, ControlledEntity { String getName(); String getProtocol(); - Long getAccountId(); + long getAccountId(); - Long getDomainId(); + long getDomainId(); Long getZoneId(); @@ -37,6 +38,8 @@ public interface HSMProfile extends Identity, InternalIdentity { boolean isEnabled(); + boolean isSystem(); + Date getCreated(); Date getRemoved(); diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java index 6c388d362a5..c956a1ec66d 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java @@ -33,73 +33,28 @@ import java.util.Date; */ public interface KMSKey extends Identity, InternalIdentity, ControlledEntity { - /** - * Get the user-friendly name of the key - */ String getName(); - /** - * Get the description of the key - */ String getDescription(); /** - * Get the provider-specific KEK label/ID - * (internal identifier used by the KMS provider) + * Provider-specific KEK label/ID (internal identifier used by the KMS provider) */ String getKekLabel(); - /** - * Get the purpose of this key - */ KeyPurpose getPurpose(); - /** - * Get the zone ID where this key is valid - */ Long getZoneId(); - /** - * Get the KMS provider name (e.g., "database", "pkcs11") - */ - String getProviderName(); - - /** - * Get the encryption algorithm (e.g., "AES/GCM/NoPadding") - */ String getAlgorithm(); - /** - * Get the key size in bits (e.g., 128, 192, 256) - */ Integer getKeyBits(); - /** - * Get the current state of the key - */ - State getState(); + boolean isEnabled(); - /** - * Get the creation timestamp - */ Date getCreated(); - /** - * Get the removal timestamp (null if not removed) - */ Date getRemoved(); - /** - * Key state enumeration - */ - enum State { - /** Key is active and can be used for encryption/decryption */ - Enabled, - /** Key is disabled and cannot be used for new operations */ - Disabled, - /** Key is soft-deleted */ - Deleted - } - Long getHsmProfileId(); } diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java index 243569a06f5..21f5f074c1f 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java @@ -17,11 +17,10 @@ package org.apache.cloudstack.kms; +import com.cloud.user.Account; import com.cloud.utils.component.Manager; import org.apache.cloudstack.api.command.admin.kms.MigrateVolumesToKMSCmd; -import org.apache.cloudstack.api.command.admin.kms.RotateKMSKeyCmd; -import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.api.command.user.kms.RotateKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.CreateKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.DeleteKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.ListKMSKeysCmd; @@ -34,41 +33,16 @@ import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.framework.kms.KMSProvider; -import org.apache.cloudstack.framework.kms.KeyPurpose; import org.apache.cloudstack.framework.kms.WrappedKey; import java.util.List; -/** - * Manager interface for Key Management Service operations. - * Provides high-level API for cryptographic key management with zone-scoping, - * provider abstraction, and integration with CloudStack's configuration system. - */ public interface KMSManager extends Manager, Configurable { - // ==================== Configuration Keys ==================== - - /** - * Zone-scoped: enable KMS for a specific zone - * When false (default), new volumes use legacy passphrase encryption - * When true, new volumes use KMS envelope encryption - */ - ConfigKey KMSEnabled = new ConfigKey<>( - "Advanced", - Boolean.class, - "kms.enabled", - "false", - "Enable Key Management Service for disk encryption in this zone", - true, - ConfigKey.Scope.Zone - ); - - /** - * Global: DEK size in bits for volume encryption - * Supported: 128, 192, 256 - */ ConfigKey KMSDekSizeBits = new ConfigKey<>( "Advanced", Integer.class, @@ -79,9 +53,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - /** - * Global: retry count for transient KMS failures - */ ConfigKey KMSRetryCount = new ConfigKey<>( "Advanced", Integer.class, @@ -92,9 +63,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - /** - * Global: retry delay in milliseconds - */ ConfigKey KMSRetryDelayMs = new ConfigKey<>( "Advanced", Integer.class, @@ -105,9 +73,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - /** - * Global: timeout for KMS operations in seconds - */ ConfigKey KMSOperationTimeoutSec = new ConfigKey<>( "Advanced", Integer.class, @@ -118,9 +83,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - /** - * Global: batch size for background rewrap operations - */ ConfigKey KMSRewrapBatchSize = new ConfigKey<>( "Advanced", Integer.class, @@ -131,9 +93,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - /** - * Global: interval for background rewrap job - */ ConfigKey KMSRewrapIntervalMs = new ConfigKey<>( "Advanced", Long.class, @@ -144,8 +103,6 @@ public interface KMSManager extends Manager, Configurable { ConfigKey.Scope.Global ); - // ==================== Provider Management ==================== - /** * List all registered KMS providers * @@ -161,16 +118,6 @@ public interface KMSManager extends Manager, Configurable { */ KMSProvider getKMSProvider(String name); - /** - * Check if KMS is enabled for a zone - * - * @param zoneId the zone ID - * @return true if KMS is enabled - */ - boolean isKmsEnabled(Long zoneId); - - // ==================== DEK Operations ==================== - /** * Unwrap a DEK from a wrapped key * SECURITY: Caller must zeroize returned byte array after use! @@ -182,32 +129,27 @@ public interface KMSManager extends Manager, Configurable { */ byte[] unwrapVolumeKey(WrappedKey wrappedKey, Long zoneId) throws KMSException; - // ==================== Health & Status ==================== - - // ==================== User KEK Management ==================== - - /** - * List KMS keys accessible to a user account - * - * @param accountId the account ID - * @param domainId the domain ID - * @param zoneId optional zone filter - * @param purpose optional purpose filter - * @param state optional state filter - * @return list of accessible KMS keys - */ - List listUserKMSKeys(Long accountId, Long domainId, Long zoneId, - KeyPurpose purpose, KMSKey.State state); - /** * Check if caller has permission to use a KMS key * * @param callerAccountId the caller's account ID - * @param key the KMS key + * @param key the KMS key * @return true if caller has permission */ boolean hasPermission(Long callerAccountId, KMSKey key); + /** + * Validates that the KMS key can be used for volume encryption: key exists, not deleted, + * caller has access, key state is Enabled, and key purpose is VOLUME_ENCRYPTION. + * No-op if kmsKeyId is null. + * + * @param caller the caller's account + * @param kmsKeyId the KMS key database ID + * @param zoneId the zone ID of the target resource (volume/VM) + * @throws InvalidParameterValueException if key not found, deleted, disabled, wrong purpose, or zone mismatch + * @throws PermissionDeniedException if caller lacks access + */ + void checkKmsKeyForVolumeEncryption(Account caller, Long kmsKeyId, Long zoneId); /** * Unwrap a DEK by wrapped key ID, trying multiple KEK versions if needed @@ -221,15 +163,13 @@ public interface KMSManager extends Manager, Configurable { /** * Generate and wrap a DEK using a specific KMS key UUID * - * @param kmsKey the KMS key + * @param kmsKey the KMS key * @param callerAccountId the caller's account ID * @return wrapped key ready for database storage * @throws KMSException if operation fails */ WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException; - // ==================== API Response Methods ==================== - /** * Create a KMS key and return the response object. * Handles validation, account resolution, and permission checks. @@ -269,8 +209,6 @@ public interface KMSManager extends Manager, Configurable { */ SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException; - // ==================== Admin Operations ==================== - /** * Rotate KEK by creating new version and scheduling gradual re-encryption * @@ -297,8 +235,6 @@ public interface KMSManager extends Manager, Configurable { */ boolean deleteKMSKeysByAccountId(Long accountId); - // ==================== HSM Profile Management ==================== - /** * Add a new HSM profile * @@ -314,7 +250,7 @@ public interface KMSManager extends Manager, Configurable { * @param cmd the list command * @return list of HSM profiles */ - List listHSMProfiles(ListHSMProfilesCmd cmd); + ListResponse listHSMProfiles(ListHSMProfilesCmd cmd); /** * Delete an HSM profile diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index aff8942eefd..c69ac85a95b 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -1947,10 +1947,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati return volume; } - Long zoneId = volume.getDataCenterId(); - - // Check if KMS is enabled for zone AND KMS key is provided - if (kmsManager != null && kmsManager.isKmsEnabled(zoneId) && kmsKey != null) { + if (kmsKey != null) { // Determine caller account ID if not provided if (callerAccountId == null) { callerAccountId = volume.getAccountId(); diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java index a4b109aba31..4945d08a54f 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java @@ -177,4 +177,6 @@ public interface VolumeDao extends GenericDao, StateDao implements Vol if (domainId != null) { sc.setParameters("domainId", domainId); } - Integer count = getCount(sc); - List volumes = listBy(sc, filter); - return new Pair<>(volumes, count); + return searchAndCount(sc, filter); } @Override @@ -973,4 +971,12 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol sc.and(sc.entity().getState(), SearchCriteria.Op.IN, (Object[]) states); return sc.find(); } + + @Override + public boolean existsWithKmsKey(long kmsKeyId) { + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("notDestroyed", Volume.State.Expunged, Volume.State.Destroy); + return findOneBy(sc) != null; + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java index a084ccdcf57..cd20b8a74fe 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.kms; +import org.apache.cloudstack.api.ResourceDetail; + import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -24,8 +26,6 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; -import org.apache.cloudstack.api.ResourceDetail; - @Entity @Table(name = "kms_hsm_profile_details") public class HSMProfileDetailsVO implements ResourceDetail { @@ -77,7 +77,7 @@ public class HSMProfileDetailsVO implements ResourceDetail { public boolean isDisplay() { return true; } - + public void setValue(String value) { this.value = value; } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java index d2455f60326..fdf1c78c693 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java @@ -17,8 +17,7 @@ package org.apache.cloudstack.kms; -import java.util.Date; -import java.util.UUID; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; import javax.persistence.Entity; @@ -26,6 +25,8 @@ import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; @Entity @Table(name = "kms_hsm_profiles") @@ -60,6 +61,9 @@ public class HSMProfileVO implements HSMProfile { @Column(name = "enabled") private boolean enabled; + @Column(name = "system") + private boolean system; + @Column(name = "created") private Date created; @@ -69,6 +73,7 @@ public class HSMProfileVO implements HSMProfile { public HSMProfileVO() { this.uuid = UUID.randomUUID().toString(); this.created = new Date(); + this.system = false; } public HSMProfileVO(String name, String protocol, Long accountId, Long domainId, Long zoneId, String vendorName) { @@ -80,9 +85,17 @@ public class HSMProfileVO implements HSMProfile { this.zoneId = zoneId; this.vendorName = vendorName; this.enabled = true; + this.system = false; this.created = new Date(); } + @Override + public String toString() { + return String.format("HSMProfileVO %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "name", "protocol", "system", "enabled")); + } + @Override public long getId() { return id; @@ -104,13 +117,13 @@ public class HSMProfileVO implements HSMProfile { } @Override - public Long getAccountId() { - return accountId; + public long getAccountId() { + return accountId == null ? -1 : accountId; } @Override - public Long getDomainId() { - return domainId; + public long getDomainId() { + return domainId == null ? -1 : domainId; } @Override @@ -138,8 +151,13 @@ public class HSMProfileVO implements HSMProfile { return removed; } - public void setName(String name) { - this.name = name; + @Override + public Class getEntityType() { + return HSMProfile.class; + } + + public void setRemoved(Date removed) { + this.removed = removed; } public void setEnabled(boolean enabled) { @@ -150,7 +168,16 @@ public class HSMProfileVO implements HSMProfile { this.vendorName = vendorName; } - public void setRemoved(Date removed) { - this.removed = removed; + public void setName(String name) { + this.name = name; + } + + @Override + public boolean isSystem() { + return system; + } + + public void setSystem(boolean system) { + this.system = system; } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java index 6f2030561e0..e78fdaba53d 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java @@ -43,73 +43,47 @@ import java.util.UUID; @Table(name = "kms_kek_versions") public class KMSKekVersionVO { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Column(name = "uuid", nullable = false, unique = true) - private String uuid; - - @Column(name = "kms_key_id", nullable = false) - private Long kmsKeyId; - - @Column(name = "version_number", nullable = false) - private Integer versionNumber; - - @Column(name = "kek_label", nullable = false) - private String kekLabel; - - @Column(name = "status", nullable = false, length = 32) - @Enumerated(EnumType.STRING) - private Status status; - - @Column(name = "hsm_profile_id") - private Long hsmProfileId; - - @Column(name = "hsm_key_label") - private String hsmKeyLabel; - - @Column(name = GenericDao.CREATED_COLUMN, nullable = false) - @Temporal(TemporalType.TIMESTAMP) - private Date created; - - @Column(name = GenericDao.REMOVED_COLUMN) - @Temporal(TemporalType.TIMESTAMP) - private Date removed; - - /** - * Status of a KEK version - */ public enum Status { /** - * Active version - used for new encryption operations + * Used for new encryption operations */ Active, /** - * Previous version - still usable for decryption during rotation + * Still usable for decryption during key rotation */ Previous, /** - * Archived version - no longer used (after re-encryption complete) + * No longer used; all wrapped keys have been re-encrypted */ Archived } - public KMSKekVersionVO() { - this.uuid = UUID.randomUUID().toString(); - this.created = new Date(); - this.status = Status.Active; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "uuid", nullable = false) + private String uuid; + @Column(name = "kms_key_id", nullable = false) + private Long kmsKeyId; + @Column(name = "version_number", nullable = false) + private Integer versionNumber; + @Column(name = "kek_label", nullable = false) + private String kekLabel; + @Column(name = "status", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private Status status; + @Column(name = "hsm_profile_id") + private Long hsmProfileId; + @Column(name = "hsm_key_label") + private String hsmKeyLabel; + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; - /** - * Constructor for creating a new KEK version - * - * @param kmsKeyId the KMS key ID this version belongs to - * @param versionNumber the version number (1, 2, 3, ...) - * @param kekLabel the provider-specific KEK label - * @param status the status (typically Active for new versions) - */ public KMSKekVersionVO(Long kmsKeyId, Integer versionNumber, String kekLabel, Status status) { this(); this.kmsKeyId = kmsKeyId; @@ -118,6 +92,12 @@ public class KMSKekVersionVO { this.status = status; } + public KMSKekVersionVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.status = Status.Active; + } + public Long getId() { return id; } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java index 1bd770861e0..bffd06d59e0 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java @@ -47,7 +47,7 @@ public class KMSKeyVO implements KMSKey { @Column(name = "id") private Long id; - @Column(name = "uuid", nullable = false, unique = true) + @Column(name = "uuid", nullable = false) private String uuid; @Column(name = "name", nullable = false) @@ -72,18 +72,14 @@ public class KMSKeyVO implements KMSKey { @Column(name = "zone_id", nullable = false) private Long zoneId; - @Column(name = "provider_name", nullable = false, length = 64) - private String providerName; - @Column(name = "algorithm", nullable = false, length = 64) private String algorithm; @Column(name = "key_bits", nullable = false) private Integer keyBits; - @Column(name = "state", nullable = false, length = 32) - @Enumerated(EnumType.STRING) - private State state; + @Column(name = "enabled", nullable = false) + private boolean enabled; @Column(name = "hsm_profile_id") private Long hsmProfileId; @@ -96,15 +92,9 @@ public class KMSKeyVO implements KMSKey { @Temporal(TemporalType.TIMESTAMP) private Date removed; - public KMSKeyVO() { - this.uuid = UUID.randomUUID().toString(); - this.created = new Date(); - this.state = State.Enabled; - } - public KMSKeyVO(String name, String description, String kekLabel, KeyPurpose purpose, - Long accountId, Long domainId, Long zoneId, String providerName, - String algorithm, Integer keyBits) { + Long accountId, Long domainId, Long zoneId, + String algorithm, Integer keyBits) { this(); this.name = name; this.description = description; @@ -113,21 +103,34 @@ public class KMSKeyVO implements KMSKey { this.accountId = accountId; this.domainId = domainId; this.zoneId = zoneId; - this.providerName = providerName; this.algorithm = algorithm; this.keyBits = keyBits; } + public KMSKeyVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.enabled = true; + } + @Override public long getId() { return id; } + public void setId(Long id) { + this.id = id; + } + @Override public String getUuid() { return uuid; } + public void setUuid(String uuid) { + this.uuid = uuid; + } + @Override public String getName() { return name; @@ -153,11 +156,6 @@ public class KMSKeyVO implements KMSKey { return zoneId; } - @Override - public String getProviderName() { - return providerName; - } - @Override public String getAlgorithm() { return algorithm; @@ -169,8 +167,8 @@ public class KMSKeyVO implements KMSKey { } @Override - public State getState() { - return state; + public boolean isEnabled() { + return enabled; } @Override @@ -183,75 +181,6 @@ public class KMSKeyVO implements KMSKey { return removed; } - // ControlledEntity interface methods - - @Override - public long getAccountId() { - return accountId; - } - - @Override - public long getDomainId() { - return domainId; - } - - @Override - public Class getEntityType() { - return KMSKey.class; - } - - public void setId(Long id) { - this.id = id; - } - - public void setUuid(String uuid) { - this.uuid = uuid; - } - - public void setName(String name) { - this.name = name; - } - - public void setDescription(String description) { - this.description = description; - } - - public void setKekLabel(String kekLabel) { - this.kekLabel = kekLabel; - } - - public void setPurpose(KeyPurpose purpose) { - this.purpose = purpose; - } - - public void setAccountId(Long accountId) { - this.accountId = accountId; - } - - public void setDomainId(Long domainId) { - this.domainId = domainId; - } - - public void setZoneId(Long zoneId) { - this.zoneId = zoneId; - } - - public void setProviderName(String providerName) { - this.providerName = providerName; - } - - public void setAlgorithm(String algorithm) { - this.algorithm = algorithm; - } - - public void setKeyBits(Integer keyBits) { - this.keyBits = keyBits; - } - - public void setState(State state) { - this.state = state; - } - @Override public Long getHsmProfileId() { return hsmProfileId; @@ -261,17 +190,73 @@ public class KMSKeyVO implements KMSKey { this.hsmProfileId = hsmProfileId; } + public void setRemoved(Date removed) { + this.removed = removed; + } + public void setCreated(Date created) { this.created = created; } - public void setRemoved(Date removed) { - this.removed = removed; + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + public void setPurpose(KeyPurpose purpose) { + this.purpose = purpose; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + @Override + public long getDomainId() { + return domainId; + } + + public void setDomainId(Long domainId) { + this.domainId = domainId; + } + + @Override + public Class getEntityType() { + return KMSKey.class; } @Override public String toString() { return String.format("KMSKey %s", - ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "purpose", "accountId", "zoneId", "state")); + ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "purpose", + "accountId", "zoneId", "enabled")); } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java index 827d87612c1..d444588a32a 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java @@ -46,7 +46,7 @@ public class KMSWrappedKeyVO { @Column(name = "id") private Long id; - @Column(name = "uuid", nullable = false, unique = true) + @Column(name = "uuid", nullable = false) private String uuid; @Column(name = "kms_key_id") @@ -58,7 +58,7 @@ public class KMSWrappedKeyVO { @Column(name = "zone_id", nullable = false) private Long zoneId; - @Column(name = "wrapped_blob", nullable = false, columnDefinition = "VARBINARY(4096)") + @Column(name = "wrapped_blob", nullable = false) private byte[] wrappedBlob; @Column(name = GenericDao.CREATED_COLUMN, nullable = false) @@ -76,12 +76,16 @@ public class KMSWrappedKeyVO { this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } + public KMSWrappedKeyVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + public KMSWrappedKeyVO(KMSKeyVO kmsKey, Long kekVersionId, byte[] wrappedBlob) { this(); this.kmsKeyId = kmsKey.getId(); this.kekVersionId = kekVersionId; this.zoneId = kmsKey.getZoneId(); - // Defensive copy this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } @@ -100,11 +104,6 @@ public class KMSWrappedKeyVO { this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } - public KMSWrappedKeyVO() { - this.uuid = UUID.randomUUID().toString(); - this.created = new Date(); - } - public Long getId() { return id; } @@ -173,6 +172,7 @@ public class KMSWrappedKeyVO { public String toString() { return String.format("KMSWrappedKey %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( - this, "id", "uuid", "kmsKeyId", "kekVersionId", "accountId", "zoneId", "state", "created", "removed")); + this, "id", "uuid", "kmsKeyId", "kekVersionId", "accountId", "zoneId", "state", "created", + "removed")); } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java index 308a10a4899..a2202a18bfc 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java @@ -17,15 +17,8 @@ package org.apache.cloudstack.kms.dao; -import java.util.List; - +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.kms.HSMProfileVO; -import com.cloud.utils.db.GenericDao; - public interface HSMProfileDao extends GenericDao { - List listByAccountId(Long accountId); - List listAdminProfiles(); - List listAdminProfiles(Long zoneId); - HSMProfileVO findByName(String name); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java index e90915d0a07..b063d2cfe74 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java @@ -17,69 +17,13 @@ package org.apache.cloudstack.kms.dao; -import java.util.List; - +import com.cloud.utils.db.GenericDaoBase; import org.apache.cloudstack.kms.HSMProfileVO; import org.springframework.stereotype.Component; -import com.cloud.utils.db.GenericDaoBase; -import com.cloud.utils.db.SearchBuilder; -import com.cloud.utils.db.SearchCriteria; -import com.cloud.utils.db.SearchCriteria.Op; - @Component public class HSMProfileDaoImpl extends GenericDaoBase implements HSMProfileDao { - - protected SearchBuilder AccountSearch; - protected SearchBuilder AdminSearch; - protected SearchBuilder NameSearch; - public HSMProfileDaoImpl() { super(); - - AccountSearch = createSearchBuilder(); - AccountSearch.and("accountId", AccountSearch.entity().getAccountId(), Op.EQ); - AccountSearch.and("removed", AccountSearch.entity().getRemoved(), Op.NULL); - AccountSearch.done(); - - AdminSearch = createSearchBuilder(); - AdminSearch.and("accountId", AdminSearch.entity().getAccountId(), Op.NULL); - AdminSearch.and("zoneId", AdminSearch.entity().getZoneId(), Op.EQ); - AdminSearch.and("removed", AdminSearch.entity().getRemoved(), Op.NULL); - AdminSearch.done(); - - NameSearch = createSearchBuilder(); - NameSearch.and("name", NameSearch.entity().getName(), Op.EQ); - NameSearch.and("removed", NameSearch.entity().getRemoved(), Op.NULL); - NameSearch.done(); - } - - @Override - public List listByAccountId(Long accountId) { - SearchCriteria sc = AccountSearch.create(); - sc.setParameters("accountId", accountId); - return listBy(sc); - } - - @Override - public List listAdminProfiles() { - SearchCriteria sc = AdminSearch.create(); - // Global admin profiles have zone_id = NULL - sc.setParameters("zoneId", (Object)null); - return listBy(sc); - } - - @Override - public List listAdminProfiles(Long zoneId) { - SearchCriteria sc = AdminSearch.create(); - sc.setParameters("zoneId", zoneId); - return listBy(sc); - } - - @Override - public HSMProfileVO findByName(String name) { - SearchCriteria sc = NameSearch.create(); - sc.setParameters("name", name); - return findOneBy(sc); } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java index db73677f527..0d5c71b9e5f 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java @@ -17,14 +17,17 @@ package org.apache.cloudstack.kms.dao; -import java.util.List; - -import org.apache.cloudstack.kms.HSMProfileDetailsVO; import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; + +import java.util.List; public interface HSMProfileDetailsDao extends GenericDao { List listByProfileId(long profileId); + void persist(long profileId, String name, String value); + HSMProfileDetailsVO findDetail(long profileId, String name); + void deleteDetails(long profileId); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java index eee59b84713..59c0ec43259 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java @@ -17,15 +17,14 @@ package org.apache.cloudstack.kms.dao; -import java.util.List; - -import org.apache.cloudstack.kms.HSMProfileDetailsVO; -import org.springframework.stereotype.Component; - import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; +import org.springframework.stereotype.Component; + +import java.util.List; @Component public class HSMProfileDetailsDaoImpl extends GenericDaoBase implements HSMProfileDetailsDao { @@ -35,11 +34,11 @@ public class HSMProfileDetailsDaoImpl extends GenericDaoBase { - /** - * Get the active version for a KMS key - */ + KMSKekVersionVO getActiveVersion(Long kmsKeyId); /** - * Get all versions that can be used for decryption (Active and Previous) + * Returns Active and Previous versions (usable for decryption) */ List getVersionsForDecryption(Long kmsKeyId); - /** - * List all versions for a KMS key - */ List listByKmsKeyId(Long kmsKeyId); - /** - * Find a specific version by KMS key ID and version number - */ KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber); - /** - * Find a KEK version by KEK label - */ KMSKekVersionVO findByKekLabel(String kekLabel); - /** - * Find all KEK versions with a specific status - * (useful for background jobs to find versions needing processing) - */ List findByStatus(KMSKekVersionVO.Status status); + + List listByHsmProfileId(Long hsmProfileId); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java index b34c6f020c7..841ab8c03d9 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java @@ -23,6 +23,7 @@ import com.cloud.utils.db.SearchCriteria; import org.apache.cloudstack.kms.KMSKekVersionVO; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; @Component @@ -36,6 +37,7 @@ public class KMSKekVersionDaoImpl extends GenericDaoBase allFieldSearch.and("status", allFieldSearch.entity().getStatus(), SearchCriteria.Op.IN); allFieldSearch.and("versionNumber", allFieldSearch.entity().getVersionNumber(), SearchCriteria.Op.EQ); allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("hsmProfileId", allFieldSearch.entity().getHsmProfileId(), SearchCriteria.Op.EQ); allFieldSearch.done(); } @@ -83,4 +85,14 @@ public class KMSKekVersionDaoImpl extends GenericDaoBase sc.setParameters("status", status); return listBy(sc); } + + @Override + public List listByHsmProfileId(Long hsmProfileId) { + if (hsmProfileId == null) { + return new ArrayList<>(); + } + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("hsmProfileId", hsmProfileId); + return listBy(sc); + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java index 3105ed236db..b6fb75c7425 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java @@ -19,40 +19,15 @@ package org.apache.cloudstack.kms.dao; import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.framework.kms.KeyPurpose; -import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.kms.KMSKeyVO; import java.util.List; public interface KMSKeyDao extends GenericDao { - /** - * Find a KMS key by KEK label and provider - */ - KMSKeyVO findByKekLabel(String kekLabel, String providerName); + List listByAccount(Long accountId, KeyPurpose purpose, Boolean enabled); - /** - * List KMS keys owned by an account - */ - List listByAccount(Long accountId, KeyPurpose purpose, KMSKey.State state); + List listByZone(Long zoneId, KeyPurpose purpose, Boolean enabled); - /** - * List KMS keys in a zone - */ - List listByZone(Long zoneId, KeyPurpose purpose, KMSKey.State state); - - /** - * List KMS keys accessible to an account (owns or in parent domain) - */ - List listAccessibleKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state); - - /** - * Count how many wrapped keys reference this KEK - */ - long countWrappedKeysByKmsKey(Long kmsKeyId); - - /** - * Count KEKs by label (to check for duplicates) - */ - long countByKekLabel(String kekLabel, String providerName); + long countByHsmProfileId(Long hsmProfileId); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java index 8a9558306f2..3f93c47c2ba 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java @@ -21,11 +21,9 @@ import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import org.apache.cloudstack.framework.kms.KeyPurpose; -import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.kms.KMSKeyVO; import org.springframework.stereotype.Component; -import javax.inject.Inject; import java.util.List; @Component @@ -33,85 +31,44 @@ public class KMSKeyDaoImpl extends GenericDaoBase implements KMS private final SearchBuilder allFieldSearch; - @Inject - private KMSWrappedKeyDao kmsWrappedKeyDao; - public KMSKeyDaoImpl() { allFieldSearch = createSearchBuilder(); allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); - allFieldSearch.and("providerName", allFieldSearch.entity().getProviderName(), SearchCriteria.Op.EQ); allFieldSearch.and("domainId", allFieldSearch.entity().getDomainId(), SearchCriteria.Op.EQ); allFieldSearch.and("accountId", allFieldSearch.entity().getAccountId(), SearchCriteria.Op.EQ); allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ); - allFieldSearch.and("state", allFieldSearch.entity().getState(), SearchCriteria.Op.EQ); + allFieldSearch.and("enabled", allFieldSearch.entity().isEnabled(), SearchCriteria.Op.EQ); allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldSearch.and("hsmProfileId", allFieldSearch.entity().getHsmProfileId(), SearchCriteria.Op.EQ); allFieldSearch.done(); } @Override - public KMSKeyVO findByKekLabel(String kekLabel, String providerName) { - SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("kekLabel", kekLabel); - sc.setParameters("providerName", providerName); - return findOneBy(sc); - } - - @Override - public List listByAccount(Long accountId, KeyPurpose purpose, KMSKey.State state) { + public List listByAccount(Long accountId, KeyPurpose purpose, Boolean enabled) { SearchCriteria sc = allFieldSearch.create(); sc.setParameters("accountId", accountId); - if (purpose != null) { - sc.setParameters("purpose", purpose); - } - if (state != null) { - sc.setParameters("state", state); - } + sc.setParametersIfNotNull("purpose", purpose); + sc.setParametersIfNotNull("enabled", enabled); return listBy(sc); } @Override - public List listByZone(Long zoneId, KeyPurpose purpose, KMSKey.State state) { + public List listByZone(Long zoneId, KeyPurpose purpose, Boolean enabled) { SearchCriteria sc = allFieldSearch.create(); sc.setParameters("zoneId", zoneId); - if (purpose != null) { - sc.setParameters("purpose", purpose); - } - if (state != null) { - sc.setParameters("state", state); - } + sc.setParametersIfNotNull("purpose", purpose); + sc.setParametersIfNotNull("enabled", enabled); return listBy(sc); } @Override - public List listAccessibleKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state) { - SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("accountId", accountId); - if (zoneId != null) { - sc.setParameters("zoneId", zoneId); - } - if (purpose != null) { - sc.setParameters("purpose", purpose); - } - if (state != null) { - sc.setParameters("state", state); - } - return listBy(sc); - } - - @Override - public long countWrappedKeysByKmsKey(Long kmsKeyId) { - if (kmsKeyId == null) { + public long countByHsmProfileId(Long hsmProfileId) { + if (hsmProfileId == null) { return 0; } - return kmsWrappedKeyDao.countByKmsKeyId(kmsKeyId); - } - - @Override - public long countByKekLabel(String kekLabel, String providerName) { SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("kekLabel", kekLabel); - sc.setParameters("providerName", providerName); + sc.setParameters("hsmProfileId", hsmProfileId); Integer count = getCount(sc); - return count != null ? count.longValue() : 0L; + return count != null ? count : 0; } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java index 2daab72f4ef..5e9e418a166 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java @@ -22,62 +22,14 @@ import org.apache.cloudstack.kms.KMSWrappedKeyVO; import java.util.List; -/** - * Data Access Object for KMS Wrapped Keys. - * This DAO is purpose-agnostic and can be used for any key purpose - * (volumes, TLS certs, config secrets, etc.) - */ public interface KMSWrappedKeyDao extends GenericDao { - /** - * List all wrapped keys using a specific KMS key - * (useful for key rotation) - * - * @param kmsKeyId the KMS key ID (FK to kms_keys) - * @return list of wrapped keys - */ - List listByKmsKeyId(Long kmsKeyId); - - /** - * List all wrapped keys in a zone - * - * @param zoneId the zone ID - * @return list of wrapped keys - */ - List listByZone(Long zoneId); - - /** - * Count wrapped keys using a specific KMS key - * - * @param kmsKeyId the KMS key ID (FK to kms_keys) - * @return count of keys - */ long countByKmsKeyId(Long kmsKeyId); /** - * List all wrapped keys using a specific KEK version - * - * @param kekVersionId the KEK version ID (FK to kms_kek_versions) - * @return list of wrapped keys - */ - List listByKekVersionId(Long kekVersionId); - - /** - * List wrapped keys using a specific KEK version with pagination limit - * (useful for batch processing in background jobs) - * - * @param kekVersionId the KEK version ID (FK to kms_kek_versions) - * @param limit maximum number of keys to return - * @return list of wrapped keys (limited to specified count) + * Limited variant for batch processing during key rotation */ List listByKekVersionId(Long kekVersionId, int limit); - /** - * List wrapped keys for a KMS key that need re-encryption (not using specified version) - * - * @param kmsKeyId the KMS key ID - * @param excludeKekVersionId the KEK version ID to exclude (keys using this version don't need rewrap) - * @return list of wrapped keys that need re-encryption - */ - List listWrappedKeysForRewrap(long kmsKeyId, long excludeKekVersionId); + long countByKekVersionId(Long kekVersionId); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java index ad924ba59ee..ae115576007 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java @@ -30,41 +30,16 @@ import java.util.List; public class KMSWrappedKeyDaoImpl extends GenericDaoBase implements KMSWrappedKeyDao { private final SearchBuilder allFieldSearch; - private final SearchBuilder rewrapExcludeVersionSearch; public KMSWrappedKeyDaoImpl() { super(); - // Search by UUID allFieldSearch = createSearchBuilder(); allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); allFieldSearch.and("kekVersionId", allFieldSearch.entity().getKekVersionId(), SearchCriteria.Op.EQ); allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); allFieldSearch.done(); - - // Search builder for excluding specific version using OR condition - rewrapExcludeVersionSearch = createSearchBuilder(); - rewrapExcludeVersionSearch.and("kmsKeyId", rewrapExcludeVersionSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - // OR group: (kekVersionId != excludeKekVersionId OR kekVersionId IS NULL) - rewrapExcludeVersionSearch.and().op("kekVersionId", rewrapExcludeVersionSearch.entity().getKekVersionId(), SearchCriteria.Op.NEQ); - rewrapExcludeVersionSearch.or("kekVersionIdNull", rewrapExcludeVersionSearch.entity().getKekVersionId(), SearchCriteria.Op.NULL); - rewrapExcludeVersionSearch.cp(); - rewrapExcludeVersionSearch.done(); - } - - @Override - public List listByKmsKeyId(Long kmsKeyId) { - SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("kmsKeyId", kmsKeyId); - return listBy(sc); - } - - @Override - public List listByZone(Long zoneId) { - SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("zoneId", zoneId); - return listBy(sc); } @Override @@ -75,13 +50,6 @@ public class KMSWrappedKeyDaoImpl extends GenericDaoBase return count != null ? count.longValue() : 0L; } - @Override - public List listByKekVersionId(Long kekVersionId) { - SearchCriteria sc = allFieldSearch.create(); - sc.setParameters("kekVersionId", kekVersionId); - return listBy(sc); - } - @Override public List listByKekVersionId(Long kekVersionId, int limit) { SearchCriteria sc = allFieldSearch.create(); @@ -91,10 +59,13 @@ public class KMSWrappedKeyDaoImpl extends GenericDaoBase } @Override - public List listWrappedKeysForRewrap(long kmsKeyId, long excludeKekVersionId) { - SearchCriteria sc = rewrapExcludeVersionSearch.create(); - sc.setParameters("kmsKeyId", kmsKeyId); - sc.setParameters("kekVersionId", excludeKekVersionId); - return listBy(sc); + public long countByKekVersionId(Long kekVersionId) { + if (kekVersionId == null) { + return 0; + } + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kekVersionId", kekVersionId); + Integer count = getCount(sc); + return count != null ? count.longValue() : 0L; } } diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 9fa7b7708f8..74b010e05fd 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`kms_hsm_profiles` ( -- Metadata `vendor_name` VARCHAR(64) COMMENT 'HSM vendor (Thales, AWS, SoftHSM, etc.)', `enabled` BOOLEAN NOT NULL DEFAULT TRUE, + `system` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'System profile (globally available, root admin only)', `created` DATETIME NOT NULL, `removed` DATETIME, @@ -167,19 +168,17 @@ CREATE TABLE IF NOT EXISTS `cloud`.`kms_keys` ( `account_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning account', `domain_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning domain', `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone where key is valid', - `provider_name` VARCHAR(64) NOT NULL COMMENT 'KMS provider (database, pkcs11, etc.)', `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm', `key_bits` INT NOT NULL DEFAULT 256 COMMENT 'Key size in bits', - `state` VARCHAR(32) NOT NULL DEFAULT 'Enabled' COMMENT 'Enabled, Disabled, or Deleted', - `hsm_profile_id` BIGINT UNSIGNED COMMENT 'Current HSM profile ID for this key', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether the key is enabled for new cryptographic operations', + `hsm_profile_id` BIGINT UNSIGNED NOT NULL COMMENT 'Current HSM profile ID for this key', `created` DATETIME NOT NULL COMMENT 'Creation timestamp', `removed` DATETIME COMMENT 'Removal timestamp for soft delete', PRIMARY KEY (`id`), UNIQUE KEY `uk_uuid` (`uuid`), - INDEX `idx_account_purpose` (`account_id`, `purpose`, `state`), - INDEX `idx_domain_purpose` (`domain_id`, `purpose`, `state`), - INDEX `idx_zone_state` (`zone_id`, `state`), - INDEX `idx_kek_label_provider` (`kek_label`, `provider_name`), + INDEX `idx_account_purpose` (`account_id`, `purpose`, `enabled`), + INDEX `idx_domain_purpose` (`domain_id`, `purpose`, `enabled`), + INDEX `idx_zone_enabled` (`zone_id`, `enabled`), CONSTRAINT `fk_kms_keys__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, CONSTRAINT `fk_kms_keys__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE, CONSTRAINT `fk_kms_keys__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql index 847d80c3fc4..8ba7e5c6df7 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql @@ -41,6 +41,8 @@ SELECT `volumes`.`external_uuid` AS `external_uuid`, `volumes`.`encrypt_format` AS `encrypt_format`, `volumes`.`kms_key_id` AS `kms_key_id`, + `kms_keys`.`uuid` AS `kms_key_uuid`, + `kms_keys`.`name` AS `kms_key_name`, `volumes`.`kms_wrapped_key_id` AS `kms_wrapped_key_id`, `volumes`.`delete_protection` AS `delete_protection`, `account`.`id` AS `account_id`, @@ -118,7 +120,7 @@ SELECT `resource_tag_domain`.`uuid` AS `tag_domain_uuid`, `resource_tag_domain`.`name` AS `tag_domain_name` FROM - ((((((((((((((((((`volumes` + (((((((((((((((((((`volumes` JOIN `account`ON ((`volumes`.`account_id` = `account`.`id`))) JOIN `domain`ON @@ -131,8 +133,10 @@ LEFT JOIN `vm_instance`ON ((`volumes`.`instance_id` = `vm_instance`.`id`))) LEFT JOIN `user_vm`ON ((`user_vm`.`id` = `vm_instance`.`id`))) -LEFT JOIN `volume_store_ref`ON +LEFT JOIN `volume_store_ref` ON ((`volumes`.`id` = `volume_store_ref`.`volume_id`))) +LEFT JOIN `kms_keys` ON + ((`volumes`.`kms_key_id` = `kms_keys`.`id`))) LEFT JOIN `service_offering`ON ((`vm_instance`.`service_offering_id` = `service_offering`.`id`))) LEFT JOIN `disk_offering`ON diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java index 977e7e62e8e..8f15ad24ac6 100644 --- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java @@ -122,8 +122,6 @@ public class KMSException extends CloudRuntimeException { "KEK not found: " + kekId); } - // Static factory methods for common error types - public static KMSException keyAlreadyExists(String details) { return new KMSException(ErrorType.KEY_ALREADY_EXISTS, "Key already exists: " + details); diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java index 756ab792f92..cea8ccd82a9 100644 --- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java @@ -17,9 +17,8 @@ package org.apache.cloudstack.framework.kms; -import org.apache.cloudstack.framework.config.Configurable; - import com.cloud.utils.component.Adapter; +import org.apache.cloudstack.framework.config.Configurable; /** * Abstract provider contract for Key Management Service operations. @@ -44,14 +43,12 @@ public interface KMSProvider extends Configurable, Adapter { */ String getProviderName(); - // ==================== KEK Management ==================== - /** * Create a new Key Encryption Key (KEK) in the secure backend with explicit HSM profile. * - * @param purpose the purpose/scope for this KEK - * @param label human-readable label for the KEK (must be unique within purpose) - * @param keyBits key size in bits (typically 128, 192, or 256) + * @param purpose the purpose/scope for this KEK + * @param label human-readable label for the KEK (must be unique within purpose) + * @param keyBits key size in bits (typically 128, 192, or 256) * @param hsmProfileId optional HSM profile ID to create the KEK in (null for auto-resolution/default) * @return the KEK identifier (label or handle) for later reference * @throws KMSException if KEK creation fails @@ -91,14 +88,12 @@ public interface KMSProvider extends Configurable, Adapter { */ boolean isKekAvailable(String kekId) throws KMSException; - // ==================== DEK Operations ==================== - /** * Wrap (encrypt) a plaintext Data Encryption Key with a KEK using explicit HSM profile. * - * @param plainDek the plaintext DEK to wrap (caller must zeroize after call) - * @param purpose the intended purpose of this DEK - * @param kekLabel the label of the KEK to use for wrapping + * @param plainDek the plaintext DEK to wrap (caller must zeroize after call) + * @param purpose the intended purpose of this DEK + * @param kekLabel the label of the KEK to use for wrapping * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) * @return WrappedKey containing the encrypted DEK and metadata * @throws KMSException if wrapping fails or KEK not found @@ -124,7 +119,7 @@ public interface KMSProvider extends Configurable, Adapter { *

* SECURITY: Caller MUST zeroize the returned byte array after use * - * @param wrappedKey the wrapped key to decrypt + * @param wrappedKey the wrapped key to decrypt * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) * @return plaintext DEK (caller must zeroize!) * @throws KMSException if unwrapping fails or KEK not found @@ -149,14 +144,15 @@ public interface KMSProvider extends Configurable, Adapter { * Generate a new random DEK and immediately wrap it with a KEK using explicit HSM profile. * (convenience method combining generation + wrapping) * - * @param purpose the intended purpose of the new DEK - * @param kekLabel the label of the KEK to use for wrapping - * @param keyBits DEK size in bits (typically 128, 192, or 256) + * @param purpose the intended purpose of the new DEK + * @param kekLabel the label of the KEK to use for wrapping + * @param keyBits DEK size in bits (typically 128, 192, or 256) * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) * @return WrappedKey containing the newly generated and wrapped DEK * @throws KMSException if generation or wrapping fails */ - WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, Long hsmProfileId) throws KMSException; + WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException; /** * Generate a new random DEK and immediately wrap it with a KEK. @@ -177,8 +173,8 @@ public interface KMSProvider extends Configurable, Adapter { * Rewrap a DEK with a different KEK (used during key rotation) using explicit target HSM profile. * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK. * - * @param oldWrappedKey the currently wrapped key - * @param newKekLabel the label of the new KEK to wrap with + * @param oldWrappedKey the currently wrapped key + * @param newKekLabel the label of the new KEK to wrap with * @param targetHsmProfileId optional target HSM profile ID to wrap with (null for auto-resolution/default) * @return new WrappedKey encrypted with the new KEK * @throws KMSException if rewrapping fails @@ -199,8 +195,6 @@ public interface KMSProvider extends Configurable, Adapter { return rewrapKey(oldWrappedKey, newKekLabel, null); } - // ==================== Health & Status ==================== - /** * Perform health check on the provider backend * @@ -208,4 +202,18 @@ public interface KMSProvider extends Configurable, Adapter { * @throws KMSException if health check fails with critical error */ boolean healthCheck() throws KMSException; + + /** + * Invalidates any cached state (config, sessions) associated with the given HSM profile. + * Must be called after an HSM profile is updated or deleted so that the next operation + * re-reads the profile details from the database instead of using stale cached values. + * + *

Providers that do not cache per-profile state (e.g. the database provider) can + * leave this as a no-op. + * + * @param profileId the HSM profile ID whose cache should be evicted + */ + default void invalidateProfileCache(Long profileId) { + // no-op for providers that don't cache per-profile state + } } diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java index be7cf069d91..835e85656b3 100644 --- a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.kms.provider; import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.crypt.DBEncryptionUtil; import com.google.crypto.tink.subtle.AesGcmJce; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.kms.KMSException; @@ -26,7 +27,6 @@ import org.apache.cloudstack.framework.kms.KeyPurpose; import org.apache.cloudstack.framework.kms.WrappedKey; import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; import org.apache.cloudstack.kms.provider.database.dao.KMSDatabaseKekObjectDao; -import com.cloud.utils.crypt.DBEncryptionUtil; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -34,14 +34,10 @@ import org.apache.logging.log4j.Logger; import javax.inject.Inject; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; -import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Date; -import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; /** * Database-backed KMS provider that stores master KEKs in a PKCS#11-like object table. @@ -52,26 +48,14 @@ import java.util.concurrent.ConcurrentHashMap; * CloudStack's existing DBEncryptionUtil, with PKCS#11-compatible attributes. */ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { - // Configuration keys - public static final ConfigKey CacheEnabled = new ConfigKey<>( - "Advanced", - Boolean.class, - "kms.database.cache.enabled", - "true", - "Enable in-memory caching of KEKs for better performance", - true, - ConfigKey.Scope.Global - ); private static final Logger logger = LogManager.getLogger(DatabaseKMSProvider.class); private static final String PROVIDER_NAME = "database"; private static final int GCM_IV_LENGTH = 12; // 96 bits recommended for GCM private static final int GCM_TAG_LENGTH = 16; // 128 bits private static final String ALGORITHM = "AES/GCM/NoPadding"; - // PKCS#11 constants private static final String CKO_SECRET_KEY = "CKO_SECRET_KEY"; private static final String CKK_AES = "CKK_AES"; - // In-memory cache of KEKs (encrypted form cached, decrypted on demand) - private final Map kekCache = new ConcurrentHashMap<>(); + private final SecureRandom secureRandom = new SecureRandom(); @Inject private KMSDatabaseKekObjectDao kekObjectDao; @@ -97,28 +81,24 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { label = generateKekLabel(purpose); } - // Check if KEK already exists if (kekObjectDao.existsByLabel(label)) { throw KMSException.keyAlreadyExists("KEK with label " + label + " already exists"); } + byte[] kekBytes = new byte[keyBits / 8]; try { - // Generate random KEK - byte[] kekBytes = new byte[keyBits / 8]; secureRandom.nextBytes(kekBytes); - // Encrypt the KEK material using DBEncryptionUtil (Base64 encode first, then encrypt) + // Base64 encode then encrypt the KEK material using DBEncryptionUtil String kekBase64 = Base64.getEncoder().encodeToString(kekBytes); String encryptedKek = DBEncryptionUtil.encrypt(kekBase64); byte[] encryptedKekBytes = encryptedKek.getBytes(StandardCharsets.UTF_8); - // Create PKCS#11-like object KMSDatabaseKekObjectVO kekObject = new KMSDatabaseKekObjectVO(label, purpose, keyBits, encryptedKekBytes); kekObject.setObjectClass(CKO_SECRET_KEY); kekObject.setKeyType(CKK_AES); kekObject.setObjectId(label.getBytes(StandardCharsets.UTF_8)); kekObject.setAlgorithm(ALGORITHM); - // PKCS#11 attributes for KEK kekObject.setIsSensitive(true); kekObject.setIsExtractable(false); kekObject.setIsToken(true); @@ -131,31 +111,17 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { kekObjectDao.persist(kekObject); - // Cache the KEK - if (CacheEnabled.value()) { - kekCache.put(label, kekBytes); - } - - logger.info("Created KEK with label {} for purpose {} (PKCS#11 object ID: {})", label, purpose, kekObject.getId()); + logger.info("Created KEK with label {} for purpose {} (PKCS#11 object ID: {})", label, purpose, + kekObject.getId()); return label; } catch (Exception e) { throw KMSException.kekOperationFailed("Failed to create KEK: " + e.getMessage(), e); + } finally { + Arrays.fill(kekBytes, (byte) 0); } } - @Override - public String getConfigComponentName() { - return DatabaseKMSProvider.class.getSimpleName(); - } - - @Override - public ConfigKey[] getConfigKeys() { - return new ConfigKey[]{ - CacheEnabled - }; - } - @Override public void deleteKek(String kekId) throws KMSException { KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId); @@ -166,13 +132,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { try { kekObjectDao.remove(kekObject.getId()); - // Remove from cache - byte[] cachedKek = kekCache.remove(kekId); - if (cachedKek != null) { - Arrays.fill(cachedKek, (byte) 0); // Zeroize - } - - // Zeroize key material in database object if (kekObject.getKeyMaterial() != null) { Arrays.fill(kekObject.getKeyMaterial(), (byte) 0); } @@ -195,7 +154,8 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } @Override - public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel, Long hsmProfileId) throws KMSException { + public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel, + Long hsmProfileId) throws KMSException { // Database provider ignores hsmProfileId return wrapKey(plainKey, purpose, kekLabel); } @@ -209,14 +169,12 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { byte[] kekBytes = loadKek(kekLabel); try { - // Create AES-GCM cipher with the KEK // Tink's AesGcmJce automatically generates a random IV and prepends it to the ciphertext AesGcmJce aesgcm = new AesGcmJce(kekBytes); + byte[] wrappedBlob = aesgcm.encrypt(plainKey, new byte[0]); - // Encrypt the DEK (Tink's encrypt returns [IV][ciphertext+tag] format) - byte[] wrappedBlob = aesgcm.encrypt(plainKey, new byte[0]); // Empty associated data - - WrappedKey wrapped = new WrappedKey(kekLabel, purpose, ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), null); + WrappedKey wrapped = new WrappedKey(kekLabel, purpose, ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), + null); logger.debug("Wrapped {} key with KEK {}", purpose, kekLabel); return wrapped; @@ -243,9 +201,7 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { byte[] kekBytes = loadKek(wrappedKey.getKekId()); try { - // Create AES-GCM cipher with the KEK AesGcmJce aesgcm = new AesGcmJce(kekBytes); - // Tink's decrypt expects [IV][ciphertext+tag] format (same as encrypt returns) byte[] blob = wrappedKey.getWrappedKeyMaterial(); if (blob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) { @@ -253,8 +209,7 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { "Invalid wrapped key format: too short"); } - // Decrypt the DEK (Tink extracts IV from the blob automatically) - byte[] plainKey = aesgcm.decrypt(blob, new byte[0]); // Empty associated data + byte[] plainKey = aesgcm.decrypt(blob, new byte[0]); logger.debug("Unwrapped {} key with KEK {}", wrappedKey.getPurpose(), wrappedKey.getKekId()); return plainKey; @@ -270,7 +225,8 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } @Override - public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, Long hsmProfileId) throws KMSException { + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException { // Database provider ignores hsmProfileId return generateAndWrapDek(purpose, kekLabel, keyBits); } @@ -281,7 +237,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { throw KMSException.invalidParameter("DEK size must be 128, 192, or 256 bits"); } - // Generate random DEK byte[] dekBytes = new byte[keyBits / 8]; secureRandom.nextBytes(dekBytes); @@ -294,18 +249,16 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } @Override - public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, Long targetHsmProfileId) throws KMSException { + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, + Long targetHsmProfileId) throws KMSException { // Database provider ignores targetHsmProfileId return rewrapKey(oldWrappedKey, newKekLabel); } @Override public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException { - // Unwrap with old KEK byte[] plainKey = unwrapKey(oldWrappedKey); - try { - // Wrap with new KEK return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel); } finally { // Zeroize plaintext DEK @@ -316,7 +269,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { @Override public boolean healthCheck() throws KMSException { try { - // Verify we can access KEK object DAO if (kekObjectDao == null) { logger.error("KMSDatabaseKekObjectDao is not initialized"); return false; @@ -329,16 +281,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } private byte[] loadKek(String kekLabel) throws KMSException { - // Check cache first - if (CacheEnabled.value()) { - byte[] cached = kekCache.get(kekLabel); - if (cached != null) { - updateLastUsed(kekLabel); - return Arrays.copyOf(cached, cached.length); // Return copy - } - } - - // Load from database KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel); if (kekObject == null || kekObject.getRemoved() != null) { @@ -346,23 +288,15 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } try { - // Decrypt the key material byte[] encryptedKekBytes = kekObject.getKeyMaterial(); if (encryptedKekBytes == null || encryptedKekBytes.length == 0) { throw KMSException.kekNotFound("KEK value is empty for label " + kekLabel); } - // Decrypt using DBEncryptionUtil String encryptedKek = new String(encryptedKekBytes, StandardCharsets.UTF_8); String kekBase64 = DBEncryptionUtil.decrypt(encryptedKek); byte[] kekBytes = Base64.getDecoder().decode(kekBase64); - // Cache for future use - if (CacheEnabled.value()) { - kekCache.put(kekLabel, Arrays.copyOf(kekBytes, kekBytes.length)); - } - - // Update last used timestamp updateLastUsed(kekLabel); return kekBytes; @@ -370,7 +304,8 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { } catch (IllegalArgumentException e) { throw KMSException.kekOperationFailed("Invalid KEK encoding for label " + kekLabel, e); } catch (Exception e) { - throw KMSException.kekOperationFailed("Failed to decrypt KEK for label " + kekLabel + ": " + e.getMessage(), e); + throw KMSException.kekOperationFailed("Failed to decrypt KEK for label " + kekLabel + ": " + e.getMessage(), + e); } } @@ -389,4 +324,14 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { private String generateKekLabel(KeyPurpose purpose) { return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); } + + @Override + public String getConfigComponentName() { + return DatabaseKMSProvider.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[0]; + } } diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java index e598a2b0914..c1c91c9cef1 100644 --- a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java @@ -50,7 +50,7 @@ public class KMSDatabaseKekObjectVO { @Column(name = "id") private Long id; - @Column(name = "uuid", nullable = false, unique = true) + @Column(name = "uuid", nullable = false) private String uuid; // PKCS#11 Object Class (CKA_CLASS) @@ -134,17 +134,12 @@ public class KMSDatabaseKekObjectVO { @Temporal(TemporalType.TIMESTAMP) private Date removed; - public KMSDatabaseKekObjectVO() { - this.uuid = UUID.randomUUID().toString(); - this.created = new Date(); - } - /** * Constructor for creating a new KEK object * - * @param label PKCS#11 label (CKA_LABEL) - * @param purpose key purpose - * @param keyBits key size in bits + * @param label PKCS#11 label (CKA_LABEL) + * @param purpose key purpose + * @param keyBits key size in bits * @param keyMaterial encrypted key material (CKA_VALUE) */ public KMSDatabaseKekObjectVO(String label, KeyPurpose purpose, Integer keyBits, byte[] keyMaterial) { @@ -157,6 +152,11 @@ public class KMSDatabaseKekObjectVO { this.startDate = new Date(); } + public KMSDatabaseKekObjectVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + public Long getId() { return id; } diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties index ec7bbd38b04..8d43cd9e08b 100644 --- a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties @@ -14,6 +14,5 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. - name=database-kms parent=kms diff --git a/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java index 487a753889c..ae86d8ced7f 100644 --- a/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java +++ b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java @@ -69,22 +69,19 @@ import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { private static final Logger logger = LogManager.getLogger(PKCS11HSMProvider.class); private static final String PROVIDER_NAME = "pkcs11"; + // AES-CBC with PKCS5Padding: FIPS-compliant (NIST SP 800-38A) with universal PKCS#11 support + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - // Constants for session management private static final long SESSION_ACQUIRE_TIMEOUT_MS = 5000L; - private static final int MAX_SESSION_RETRIES = 3; - private static final long RETRY_BACKOFF_BASE_MS = 100L; - // Valid key sizes for AES private static final int[] VALID_KEY_SIZES = {128, 192, 256}; - // Session pool per HSM profile private final Map sessionPools = new ConcurrentHashMap<>(); - // Profile configuration caching - private final Map> profileConfigCache = new ConcurrentHashMap<>(); @Inject private HSMProfileDao hsmProfileDao; @Inject @@ -107,19 +104,8 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { if (hsmProfileId == null) { throw KMSException.invalidParameter("HSM Profile ID is required for PKCS#11 provider"); } - - if (StringUtils.isEmpty(label)) { - label = generateKekLabel(purpose); - } - - HSMSessionPool pool = getSessionPool(hsmProfileId); - PKCS11Session session = null; - try { - session = pool.acquireSession(5000); - return session.generateKey(label, keyBits, purpose); - } finally { - pool.releaseSession(session); - } + final String kekLabel = StringUtils.isEmpty(label) ? generateKekLabel(purpose) : label; + return executeWithSession(hsmProfileId, session -> session.generateKey(kekLabel, keyBits, purpose)); } @Override @@ -133,10 +119,8 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { @Override public boolean isKekAvailable(String kekId) throws KMSException { - Long hsmProfileId = resolveProfileId(kekId); - if (hsmProfileId == null) return false; - try { + Long hsmProfileId = resolveProfileId(kekId); return executeWithSession(hsmProfileId, session -> session.checkKeyExists(kekId)); } catch (Exception e) { return false; @@ -151,7 +135,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } byte[] wrappedBlob = executeWithSession(hsmProfileId, session -> session.wrapKey(plainDek, kekLabel)); - return new WrappedKey(kekLabel, purpose, "AES/GCM/NoPadding", wrappedBlob, PROVIDER_NAME, new Date(), null); + return new WrappedKey(kekLabel, purpose, CIPHER_ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), null); } @Override @@ -167,34 +151,25 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { @Override public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, Long hsmProfileId) throws KMSException { - // Generate random DEK byte[] dekBytes = new byte[keyBits / 8]; new SecureRandom().nextBytes(dekBytes); try { return wrapKey(dekBytes, purpose, kekLabel, hsmProfileId); } finally { - java.util.Arrays.fill(dekBytes, (byte) 0); + Arrays.fill(dekBytes, (byte) 0); } } @Override public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, Long targetHsmProfileId) throws KMSException { - // 1. Unwrap with old KEK - byte[] plainKey = unwrapKey(oldWrappedKey, null); // Auto-resolve old profile - + byte[] plainKey = unwrapKey(oldWrappedKey, null); try { - // 2. Wrap with new KEK - Long profileId = targetHsmProfileId; - if (profileId == null) { - profileId = resolveProfileId(newKekLabel); - } - + Long profileId = targetHsmProfileId != null ? targetHsmProfileId : resolveProfileId(newKekLabel); return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel, profileId); } finally { - // Zeroize plaintext key - java.util.Arrays.fill(plainKey, (byte) 0); + Arrays.fill(plainKey, (byte) 0); } } @@ -216,17 +191,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { */ @Override public boolean healthCheck() throws KMSException { - // Test connectivity to at least one configured HSM profile if (sessionPools.isEmpty()) { logger.debug("No HSM profiles configured for health check"); - return true; // No profiles means nothing to check + return true; } boolean allHealthy = true; - for (Map.Entry entry : sessionPools.entrySet()) { - Long profileId = entry.getKey(); - HSMSessionPool pool = entry.getValue(); - if (!checkProfileHealth(profileId, pool)) { + for (Long profileId : sessionPools.keySet()) { + if (!checkProfileHealth(profileId)) { allHealthy = false; } } @@ -235,35 +207,21 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { throw KMSException.healthCheckFailed("One or more HSM profiles failed health check", null); } - return allHealthy; + return true; } - /** - * Checks health of a single HSM profile. - * - * @param profileId HSM profile ID - * @param pool Session pool for the profile - * @return true if profile is healthy, false otherwise - */ - private boolean checkProfileHealth(Long profileId, HSMSessionPool pool) { + private boolean checkProfileHealth(Long profileId) { try { - PKCS11Session testSession = pool.acquireSession(SESSION_ACQUIRE_TIMEOUT_MS); - if (testSession == null || !testSession.isValid()) { - logger.warn("Health check failed for HSM profile {}: Could not acquire valid session", profileId); - return false; - } - try { - if (testSession.keyStore != null) { - testSession.keyStore.size(); // Lightweight operation - logger.debug("Health check passed for HSM profile {}", profileId); - return true; - } else { - logger.warn("Health check failed for HSM profile {}: KeyStore is null", profileId); + Boolean result = executeWithSession(profileId, session -> { + try { + session.keyStore.size(); // Verify the HSM token is currently reachable + } catch (KeyStoreException e) { return false; } - } finally { - pool.releaseSession(testSession); - } + return true; + }); + logger.debug("Health check {} for HSM profile {}", result ? "passed" : "failed", profileId); + return result; } catch (Exception e) { logger.warn("Health check failed for HSM profile {}: {}", profileId, e.getMessage(), e); return false; @@ -299,25 +257,25 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } HSMSessionPool getSessionPool(Long profileId) { - return sessionPools.computeIfAbsent(profileId, - id -> new HSMSessionPool(id, loadProfileConfig(id))); + return sessionPools.computeIfAbsent(profileId, id -> { + Map config = loadProfileConfig(id); + int maxSessions = Integer.parseInt(config.getOrDefault("max_sessions", "10")); + return new HSMSessionPool(id, maxSessions, this); + }); } Map loadProfileConfig(Long profileId) { - return profileConfigCache.computeIfAbsent(profileId, id -> { - List details = hsmProfileDetailsDao.listByProfileId(id); - Map config = new HashMap<>(); - for (HSMProfileDetailsVO detail : details) { - String value = detail.getValue(); - if (isSensitiveKey(detail.getName())) { - value = DBEncryptionUtil.decrypt(value); - } - config.put(detail.getName(), value); + List details = hsmProfileDetailsDao.listByProfileId(profileId); + Map config = new HashMap<>(); + for (HSMProfileDetailsVO detail : details) { + String value = detail.getValue(); + if (isSensitiveKey(detail.getName())) { + value = DBEncryptionUtil.decrypt(value); } - // Validate configuration - validateProfileConfig(config); - return config; - }); + config.put(detail.getName(), value); + } + validateProfileConfig(config); + return config; } /** @@ -329,27 +287,23 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { *

  • {@code slot} or {@code token_label}: At least one required
  • *
  • {@code pin}: Required for HSM authentication
  • *
  • {@code max_sessions}: Optional, must be positive integer if provided
  • - *
  • {@code min_idle_sessions}: Optional, must be non-negative integer if provided
  • * * * @param config Configuration map from HSM profile details * @throws KMSException with {@code INVALID_PARAMETER} if validation fails */ void validateProfileConfig(Map config) throws KMSException { - // Validate required config keys String libraryPath = config.get("library"); if (StringUtils.isEmpty(libraryPath)) { throw KMSException.invalidParameter("library is required for PKCS#11 HSM profile"); } - // Validate slot or token_label (at least one required) String slot = config.get("slot"); String tokenLabel = config.get("token_label"); if (StringUtils.isEmpty(slot) && StringUtils.isEmpty(tokenLabel)) { throw KMSException.invalidParameter("Either 'slot' or 'token_label' is required for PKCS#11 HSM profile"); } - // Validate slot is numeric if provided if (!StringUtils.isEmpty(slot)) { try { Integer.parseInt(slot); @@ -358,24 +312,19 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } - // Validate PIN is present String pin = config.get("pin"); if (StringUtils.isEmpty(pin)) { throw KMSException.invalidParameter("pin is required for PKCS#11 HSM profile"); } - // Validate library points to existing file (if accessible) File libraryFile = new File(libraryPath); if (!libraryFile.exists() && !libraryFile.isAbsolute()) { - // Try to find in common library paths, but don't fail if not found - // The HSM library might be in system library path + // The HSM library might be in the system library path logger.debug("Library path {} does not exist as absolute path, will rely on system library path", libraryPath); } - // Validate max_sessions and min_idle_sessions if provided parsePositiveInteger(config, "max_sessions", "max_sessions"); - parseNonNegativeInteger(config, "min_idle_sessions", "min_idle_sessions"); } /** @@ -403,32 +352,6 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } - /** - * Parses a non-negative integer from configuration. - * - * @param config Configuration map - * @param key Configuration key - * @param errorPrefix Prefix for error messages - * @return Parsed integer value, or -1 if not provided - * @throws KMSException if value is invalid or negative - */ - private int parseNonNegativeInteger(Map config, String key, - String errorPrefix) throws KMSException { - String value = config.get(key); - if (StringUtils.isEmpty(value)) { - return -1; // Not provided - } - try { - int parsed = Integer.parseInt(value); - if (parsed < 0) { - throw KMSException.invalidParameter(errorPrefix + " must be non-negative"); - } - return parsed; - } catch (NumberFormatException e) { - throw KMSException.invalidParameter(errorPrefix + " must be a valid integer: " + value); - } - } - boolean isSensitiveKey(String key) { return key.equalsIgnoreCase("pin") || key.equalsIgnoreCase("password") || @@ -440,11 +363,6 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); } - /** - * @return The name of the component that provided this configuration - * variable. This value is saved in the database so someone can easily - * identify who provides this variable. - **/ @Override public String getConfigComponentName() { return PKCS11HSMProvider.class.getSimpleName(); @@ -455,87 +373,105 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { return new ConfigKey[0]; } - /** - * Functional interface for operations that require a PKCS#11 session. - */ + @Override + public void invalidateProfileCache(Long profileId) { + HSMSessionPool pool = sessionPools.remove(profileId); + if (pool != null) { + pool.invalidate(); + } + logger.info("Invalidated HSM session pool for profile {}", profileId); + } + @FunctionalInterface private interface SessionOperation { T execute(PKCS11Session session) throws KMSException; } - // Inner class for session pooling private static class HSMSessionPool { private final BlockingQueue availableSessions; private final Long profileId; - private final Map config; + private final PKCS11HSMProvider provider; private final int maxSessions; - private final int minIdleSessions; + // Counts total sessions (idle + active). Acquired on creation, released on close. + private final Semaphore sessionPermits; + private volatile boolean invalidated = false; - HSMSessionPool(Long profileId, Map config) { + HSMSessionPool(Long profileId, int maxSessions, PKCS11HSMProvider provider) { this.profileId = profileId; - this.config = config; - this.maxSessions = Integer.parseInt(config.getOrDefault("max_sessions", "10")); - this.minIdleSessions = Integer.parseInt(config.getOrDefault("min_idle_sessions", "2")); + this.provider = provider; + this.maxSessions = maxSessions; + this.sessionPermits = new Semaphore(maxSessions); this.availableSessions = new ArrayBlockingQueue<>(maxSessions); - - // Pre-warm - for (int i = 0; i < minIdleSessions; i++) { - try { - availableSessions.offer(createNewSession()); - } catch (Exception e) { - logger.warn("Failed to pre-warm session for profile {}: {}", profileId, e.getMessage()); - } - } } private PKCS11Session createNewSession() throws KMSException { - return new PKCS11Session(config); + // Config (including decrypted PIN) is loaded fresh each time and not stored. + return new PKCS11Session(provider.loadProfileConfig(profileId)); } PKCS11Session acquireSession(long timeoutMs) throws KMSException { - // Retry logic for session creation - Exception lastException = null; - - for (int attempt = 0; attempt < MAX_SESSION_RETRIES; attempt++) { - try { - PKCS11Session session = availableSessions.poll(); - if (session == null || !session.isValid()) { - if (session != null) { - session.close(); - } - session = createNewSession(); - } + // Try to get an existing idle session first (no semaphore change: it already owns a permit). + PKCS11Session session = availableSessions.poll(); + if (session != null) { + if (session.isValid()) { return session; - } catch (Exception e) { - lastException = e; - if (attempt < MAX_SESSION_RETRIES - 1) { - // Exponential backoff: 100ms, 200ms, 400ms - long backoffMs = RETRY_BACKOFF_BASE_MS * (1L << attempt); - try { - Thread.sleep(backoffMs); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, - "Interrupted while waiting to retry HSM session acquisition", ie); - } - logger.debug("Retrying HSM session acquisition for profile {} (attempt {}/{})", - profileId, attempt + 2, MAX_SESSION_RETRIES); - } } + // Stale idle session: discard it and free its permit so a new one can be created. + session.close(); + sessionPermits.release(); } - // All retries failed - logger.error("Failed to acquire HSM session for profile {} after {} attempts", profileId, - MAX_SESSION_RETRIES); - throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, - "Failed to acquire HSM session after " + MAX_SESSION_RETRIES + " attempts", lastException); + // Acquire a permit to create a new session, blocking up to timeoutMs if at capacity. + try { + if (!sessionPermits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) { + // One last try: a session may have been returned while we were waiting. + session = availableSessions.poll(); + if (session != null && session.isValid()) { + return session; + } + if (session != null) { + session.close(); + sessionPermits.release(); + } + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Timed out waiting for an available HSM session for profile " + profileId + + " (max=" + maxSessions + ", timeout=" + timeoutMs + "ms)"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Interrupted while waiting to acquire HSM session for profile " + profileId, e); + } + + try { + return createNewSession(); + } catch (KMSException e) { + sessionPermits.release(); + throw e; + } } void releaseSession(PKCS11Session session) { - if (session != null && session.isValid()) { - if (!availableSessions.offer(session)) { - session.close(); // Pool full - } + if (session == null) return; + if (!invalidated && session.isValid() && availableSessions.offer(session)) { + return; // session returned to the idle pool; permit stays consumed + } + // Pool is invalidated, session is stale, or the idle queue is full: close immediately. + session.close(); + sessionPermits.release(); + } + + /** + * Marks the pool as invalidated and closes all idle sessions. + * Any session currently checked out will be closed (and its permit released) when + * it is returned via {@link #releaseSession} — the invalidated flag prevents re-pooling. + */ + void invalidate() { + invalidated = true; + PKCS11Session session; + while ((session = availableSessions.poll()) != null) { + session.close(); + sessionPermits.release(); } } } @@ -547,8 +483,8 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { *

    Key operations supported: *

      *
    • Key generation: Generate AES keys directly in the HSM
    • - *
    • Key wrapping: Encrypt DEKs using KEKs stored in the HSM (AES-GCM)
    • - *
    • Key unwrapping: Decrypt DEKs using KEKs stored in the HSM (AES-GCM)
    • + *
    • Key wrapping: Encrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
    • + *
    • Key unwrapping: Decrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
    • *
    • Key deletion: Remove keys from the HSM
    • *
    • Key existence check: Verify if a key exists in the HSM
    • *
    @@ -564,13 +500,8 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { * {@link KMSException.ErrorType} values for proper retry logic and error reporting. */ private static class PKCS11Session { - // Use AES-CBC with PKCS5Padding for key wrapping - // This is FIPS-compliant (NIST SP 800-38A) and has universal PKCS#11 support - private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - private static final int IV_LENGTH = 16; // 128 bits for CBC - private static final String PROVIDER_PREFIX = "CloudStackPKCS11-"; + private static final int IV_LENGTH = 16; // 128 bits for CBC mode - private final Map config; private KeyStore keyStore; private Provider provider; private String providerName; @@ -578,13 +509,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { /** * Creates a new PKCS#11 session and connects to the HSM. + * The config map (including any sensitive values such as the PIN) is used only + * during connection setup and is not retained as a field. * * @param config HSM profile configuration containing library, slot/token_label, and pin * @throws KMSException if connection fails or configuration is invalid */ PKCS11Session(Map config) throws KMSException { - this.config = config; - connect(); + connect(config); } /** @@ -611,55 +543,54 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { *
  • {@code CONNECTION_FAILED} if HSM is unreachable or device error occurs
  • * */ - private void connect() throws KMSException { + private void connect(Map config) throws KMSException { try { - // Create unique provider name to avoid conflicts - providerName = PROVIDER_PREFIX + UUID.randomUUID().toString().substring(0, 8); + // Unique suffix ensures each session gets its own provider name in java.security.Security, + // allowing Security.removeProvider() in close() to target exactly this session's provider. + String nameSuffix = UUID.randomUUID().toString().substring(0, 8); - String configString = buildSunPKCS11Config(config); + String configString = buildSunPKCS11Config(config, nameSuffix); - // For Java 9+, use the recommended approach: get provider and configure with file - // Write config to temporary file (required by Java 9+ API) + // Java 9+ API: write config to temp file, then configure the provider tempConfigFile = Files.createTempFile("pkcs11-config-", ".cfg"); try (FileWriter writer = new FileWriter(tempConfigFile.toFile(), StandardCharsets.UTF_8)) { writer.write(configString); } - // Get the base SunPKCS11 provider and configure it Provider baseProvider = Security.getProvider("SunPKCS11"); if (baseProvider == null) { throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, "SunPKCS11 provider not available in this JVM"); } - // Configure the provider with the config file (Java 9+ API) provider = baseProvider.configure(tempConfigFile.toAbsolutePath().toString()); - // Set the provider name (it will be based on the 'name' field in config) - // Add provider to Security if not already present - if (Security.getProvider(providerName) == null) { - Security.addProvider(provider); - } else { - provider = Security.getProvider(providerName); + // Use the actual provider name so Security.removeProvider() in close() works correctly. + providerName = provider.getName(); + + // Security.addProvider returns -1 if a provider with this name is already registered. + // With the UUID-based suffix this should be impossible in practice; guard defensively. + if (Security.addProvider(provider) < 0) { + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Failed to register PKCS#11 provider '" + providerName + "': name already in use"); } - // Load PKCS#11 KeyStore keyStore = KeyStore.getInstance("PKCS11", provider); - // Get PIN for authentication String pin = config.get("pin"); if (StringUtils.isEmpty(pin)) { throw KMSException.invalidParameter("pin is required"); } char[] pinChars = pin.toCharArray(); - - // Load KeyStore with PIN (this authenticates to the HSM) keyStore.load(null, pinChars); - - // Zeroize PIN from memory Arrays.fill(pinChars, '\0'); - logger.debug("aSuccessfully connected to PKCS#11 HSM at {}", config.get("library")); + // The temp file is only needed during configure()/load(); delete it immediately + // rather than holding it until the session is eventually closed. + Files.deleteIfExists(tempConfigFile); + tempConfigFile = null; + + logger.debug("Successfully connected to PKCS#11 HSM at {}", config.get("library")); } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException e) { handlePKCS11Exception(e, "Failed to initialize PKCS#11 connection"); } catch (IOException e) { @@ -684,14 +615,17 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { * @return Configuration string for SunPKCS11 provider * @throws KMSException if required configuration is missing */ - private String buildSunPKCS11Config(Map config) throws KMSException { + private String buildSunPKCS11Config(Map config, String nameSuffix) throws KMSException { String libraryPath = config.get("library"); if (StringUtils.isEmpty(libraryPath)) { throw KMSException.invalidParameter("library is required"); } StringBuilder configBuilder = new StringBuilder(); - configBuilder.append("name=CloudStackHSM\n"); + // Include the unique suffix so that each session is registered under a distinct + // provider name (SunPKCS11-CloudStackHSM-{suffix}), preventing name collisions + // across concurrent sessions and allowing clean removal via Security.removeProvider(). + configBuilder.append("name=CloudStackHSM-").append(nameSuffix).append("\n"); configBuilder.append("library=").append(libraryPath).append("\n"); String tokenLabel = config.get("token_label"); @@ -734,7 +668,6 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } logger.warn("PKCS#11 error: {} - {}", errorMsg, context, e); - // Map PKCS#11 error codes to KMSException types if (errorMsg.contains("CKR_PIN_INCORRECT") || errorMsg.contains("PIN_INCORRECT")) { throw new KMSException(KMSException.ErrorType.AUTHENTICATION_FAILED, context + ": Incorrect PIN", e); @@ -773,17 +706,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { */ boolean isValid() { try { - // Check if KeyStore object is not null if (keyStore == null) { return false; } - // Check if Provider is still registered in Security if (provider == null || Security.getProvider(provider.getName()) == null) { return false; } - // Test with a lightweight HSM operation (get KeyStore size) keyStore.size(); return true; } catch (Exception e) { @@ -808,15 +738,10 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { */ void close() { try { - // Close KeyStore if it implements Closeable if (keyStore instanceof Closeable) { ((Closeable) keyStore).close(); } - // Logout from HSM token (if supported) - // Note: SunPKCS11 KeyStore doesn't have explicit logout, but closing should handle it - - // Remove provider from Security if (provider != null && providerName != null) { try { Security.removeProvider(providerName); @@ -825,7 +750,6 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } - // Clean up temporary config file if (tempConfigFile != null) { try { Files.deleteIfExists(tempConfigFile); @@ -1060,7 +984,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { // Minimum size: IV (16) + at least one block of ciphertext (16) if (wrappedBlob.length < IV_LENGTH + 16) { throw KMSException.invalidParameter("Wrapped blob too short: expected at least " + - (IV_LENGTH + 16) + " bytes"); + (IV_LENGTH + 16) + " bytes"); } SecretKey kek = null; diff --git a/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java index 1434e39b6c6..59e06a4c9b7 100644 --- a/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java +++ b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java @@ -17,18 +17,6 @@ package org.apache.cloudstack.kms.provider.pkcs11; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.Map; - import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.framework.kms.KeyPurpose; import org.apache.cloudstack.kms.HSMProfileDetailsVO; @@ -43,6 +31,18 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; +import java.util.Arrays; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + /** * Unit tests for PKCS11HSMProvider * Tests provider-specific logic: config loading, profile resolution, sensitive key detection @@ -128,11 +128,11 @@ public class PKCS11HSMProviderTest { when(detail2.getValue()).thenReturn("ENC(encrypted_pin)"); HSMProfileDetailsVO detail3 = mock(HSMProfileDetailsVO.class); - when(detail3.getName()).thenReturn("slot_id"); + when(detail3.getName()).thenReturn("slot"); when(detail3.getValue()).thenReturn("0"); when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( - Arrays.asList(detail1, detail2, detail3)); + Arrays.asList(detail1, detail2, detail3)); // Test Map config = provider.loadProfileConfig(testProfileId); @@ -144,7 +144,7 @@ public class PKCS11HSMProviderTest { // Note: In real code, DBEncryptionUtil.decrypt would be called // Here we just verify the structure is correct assertTrue("Config should contain pin", config.containsKey("pin")); - assertEquals("0", config.get("slot_id")); + assertEquals("0", config.get("slot")); verify(hsmProfileDetailsDao).listByProfileId(testProfileId); } @@ -152,17 +152,13 @@ public class PKCS11HSMProviderTest { /** * Test: loadProfileConfig handles empty details */ - @Test + @Test(expected = KMSException.class) public void testLoadProfileConfig_HandlesEmptyDetails() { // Setup when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList()); // Test Map config = provider.loadProfileConfig(testProfileId); - - // Verify - assertNotNull("Config should not be null", config); - assertEquals(0, config.size()); } /** @@ -201,7 +197,8 @@ public class PKCS11HSMProviderTest { // Verify assertNotNull("Label should not be null", label); assertTrue("Label should start with purpose", label.startsWith(KeyPurpose.VOLUME_ENCRYPTION.getName())); - assertTrue("Label should contain UUID", label.length() > (KeyPurpose.VOLUME_ENCRYPTION.getName() + "-kek-").length()); + assertTrue("Label should contain UUID", + label.length() > (KeyPurpose.VOLUME_ENCRYPTION.getName() + "-kek-").length()); } /** @@ -218,42 +215,30 @@ public class PKCS11HSMProviderTest { @Test(expected = KMSException.class) public void testCreateKek_RequiresProfileId() throws KMSException { provider.createKek( - KeyPurpose.VOLUME_ENCRYPTION, - "test-label", - 256, - null // null profile ID should throw exception + KeyPurpose.VOLUME_ENCRYPTION, + "test-label", + 256, + null // null profile ID should throw exception ); } - /** - * Test: loadProfileConfig caches configuration - */ - @Test - public void testLoadProfileConfig_CachesConfiguration() { - // Setup - HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class); - when(detail.getName()).thenReturn("library"); - when(detail.getValue()).thenReturn("/path/to/lib.so"); - when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail)); - - // Load twice - provider.loadProfileConfig(testProfileId); - provider.loadProfileConfig(testProfileId); - - // DAO should only be called once due to caching - verify(hsmProfileDetailsDao, times(1)).listByProfileId(testProfileId); - } - /** * Test: getSessionPool creates pool for new profile */ @Test public void testGetSessionPool_CreatesPoolForNewProfile() { // Setup - HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class); - when(detail.getName()).thenReturn("library"); - when(detail.getValue()).thenReturn("/path/to/lib.so"); - when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail)); + HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class); + when(libraryDetail.getName()).thenReturn("library"); + when(libraryDetail.getValue()).thenReturn("/path/to/lib.so"); + HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class); + when(slotDetail.getName()).thenReturn("slot"); + when(slotDetail.getValue()).thenReturn("1"); + HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class); + when(pinDetail.getName()).thenReturn("pin"); + when(pinDetail.getValue()).thenReturn("1234"); + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(libraryDetail, slotDetail, pinDetail)); // Test Object pool = provider.getSessionPool(testProfileId); @@ -269,10 +254,17 @@ public class PKCS11HSMProviderTest { @Test public void testGetSessionPool_ReusesPoolForSameProfile() { // Setup - HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class); - when(detail.getName()).thenReturn("library"); - when(detail.getValue()).thenReturn("/path/to/lib.so"); - when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail)); + HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class); + when(libraryDetail.getName()).thenReturn("library"); + when(libraryDetail.getValue()).thenReturn("/path/to/lib.so"); + HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class); + when(slotDetail.getName()).thenReturn("slot"); + when(slotDetail.getValue()).thenReturn("1"); + HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class); + when(pinDetail.getName()).thenReturn("pin"); + when(pinDetail.getValue()).thenReturn("1234"); + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(libraryDetail, slotDetail, pinDetail)); // Test Object pool1 = provider.getSessionPool(testProfileId); diff --git a/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java b/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java index e5bb9156725..a208893f6d1 100644 --- a/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java +++ b/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java @@ -81,7 +81,7 @@ public class ManagementServerMaintenanceManagerImplTest { Mockito.doReturn(expectedCount).when(jobManagerMock).countPendingNonPseudoJobs(1L); return expectedCount; } - + @Test public void countPendingJobs() { long expectedCount = prepareCountPendingJobs(); diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index 10b77c9fbbf..7349ec6dc03 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -115,7 +115,6 @@ import org.apache.cloudstack.api.response.HypervisorGuestOsResponse; import org.apache.cloudstack.api.response.IPAddressResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; import org.apache.cloudstack.api.response.InstanceGroupResponse; -import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.InternalLoadBalancerElementResponse; import org.apache.cloudstack.api.response.IpForwardingRuleResponse; import org.apache.cloudstack.api.response.IpQuarantineResponse; @@ -219,6 +218,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobManager; import org.apache.cloudstack.gui.theme.GuiThemeJoin; +import org.apache.cloudstack.kms.dao.HSMProfileDao; import org.apache.cloudstack.management.ManagementServerHost; import org.apache.cloudstack.network.BgpPeerVO; import org.apache.cloudstack.network.RoutedIpv4Manager; @@ -425,7 +425,6 @@ import com.cloud.user.AccountManager; import com.cloud.user.SSHKeyPair; import com.cloud.user.User; import com.cloud.user.UserAccount; -import org.apache.cloudstack.kms.KMSKey; import com.cloud.user.UserData; import com.cloud.user.UserStatisticsVO; import com.cloud.user.dao.UserDataDao; @@ -531,6 +530,8 @@ public class ApiResponseHelper implements ResponseGenerator { private ASNumberRangeDao asNumberRangeDao; @Inject private ASNumberDao asNumberDao; + @Inject + private HSMProfileDao hsmProfileDao; @Inject ObjectStoreDao _objectStoreDao; diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index fb97e2f3d8d..93578281a07 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -2678,6 +2678,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q Long clusterId = cmd.getClusterId(); Long serviceOfferingId = cmd.getServiceOfferingId(); Long diskOfferingId = cmd.getDiskOfferingId(); + Long kmsKeyId = cmd.getKmsKeyId(); Boolean display = cmd.getDisplay(); String state = cmd.getState(); boolean shouldListSystemVms = shouldListSystemVms(cmd, caller.getId()); @@ -2714,6 +2715,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q volumeSearchBuilder.and("uuid", volumeSearchBuilder.entity().getUuid(), SearchCriteria.Op.NNULL); volumeSearchBuilder.and("instanceId", volumeSearchBuilder.entity().getInstanceId(), SearchCriteria.Op.EQ); volumeSearchBuilder.and("dataCenterId", volumeSearchBuilder.entity().getDataCenterId(), SearchCriteria.Op.EQ); + volumeSearchBuilder.and("kmsKeyId", volumeSearchBuilder.entity().getKmsKeyId(), SearchCriteria.Op.EQ); if (cmd.isEncrypted() != null) { if (cmd.isEncrypted()) { volumeSearchBuilder.and("encryptFormat", volumeSearchBuilder.entity().getEncryptFormat(), SearchCriteria.Op.NNULL); @@ -2838,6 +2840,9 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q if (vmInstanceId != null) { sc.setParameters("instanceId", vmInstanceId); } + if (kmsKeyId != null) { + sc.setParameters("kmsKeyId", kmsKeyId); + } if (zoneId != null) { sc.setParameters("dataCenterId", zoneId); } diff --git a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java index f336d5172f2..6f8647422cd 100644 --- a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java @@ -30,7 +30,6 @@ import org.apache.cloudstack.api.response.VolumeResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.kms.KMSKekVersionVO; -import org.apache.cloudstack.kms.KMSKeyVO; import org.apache.cloudstack.kms.KMSWrappedKeyVO; import org.apache.cloudstack.kms.dao.KMSKekVersionDao; import org.apache.cloudstack.kms.dao.KMSKeyDao; @@ -296,12 +295,9 @@ public class VolumeJoinDaoImpl extends GenericDaoBaseWithTagInformation kmsProviderMap = new HashMap<>(); + private final ExecutorService kmsOperationExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "kms-operation"); + t.setDaemon(true); + return t; + }); @Inject private KMSWrappedKeyDao kmsWrappedKeyDao; @Inject @@ -93,16 +112,14 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable private HSMProfileDetailsDao hsmProfileDetailsDao; @Inject private AccountManager accountManager; - @Inject - private ResponseGenerator responseGenerator; + private DataCenterDao dataCenterDao; @Inject private VolumeDao volumeDao; @Inject private PassphraseDao passphraseDao; private List kmsProviders; - - // ==================== Provider Management ==================== + private Timer rewrapTimer; @Override public List listKMSProviders() { @@ -111,7 +128,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public KMSProvider getKMSProvider(String name) { - // Default to database provider if no name specified if (StringUtils.isEmpty(name)) { name = "database"; } @@ -129,79 +145,9 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return provider; } - @Override - public boolean isKmsEnabled(Long zoneId) { - if (zoneId == null) { - return false; - } - return KMSEnabled.valueIn(zoneId); - } - - /** - * Internal method to rotate a KEK (create new version and update KMS key state) - */ - String rotateKek(KMSKeyVO kmsKey, String oldKekLabel, String newKekLabel, int keyBits, HSMProfileVO newHSMProfile) throws KMSException { - - validateKmsEnabled(kmsKey.getZoneId()); - - if (StringUtils.isEmpty(oldKekLabel)) { - throw KMSException.invalidParameter("oldKekLabel must be specified"); - } - - if (newHSMProfile == null) { - newHSMProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); - } - - - KMSProvider provider = getKMSProvider(newHSMProfile.getProtocol()); - - try { - logger.info("Starting KEK rotation from {} to {} for kms key {}", oldKekLabel, newKekLabel, kmsKey); - - // Find KMS key by old KEK label - - // Generate new KEK label if not provided - if (StringUtils.isEmpty(newKekLabel)) { - newKekLabel = kmsKey.getPurpose().getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); - } - - // Create new KEK in provider - String finalNewKekLabel = newKekLabel; - Long newProfileId = newHSMProfile.getId(); - String newKekId = retryOperation(() -> provider.createKek(kmsKey.getPurpose(), finalNewKekLabel, keyBits, newProfileId)); - - // Create new KEK version (marks old as Previous, new as Active) - KMSKekVersionVO newVersion = createKekVersion(kmsKey.getId(), newKekId, newProfileId); - - // Update KMS Key with new profile if different - if (!newProfileId.equals(kmsKey.getHsmProfileId())) { - kmsKey.setHsmProfileId(newProfileId); - kmsKeyDao.update(kmsKey.getId(), kmsKey); - logger.info("Updated KMS key {} to use HSM profile {}", kmsKey, newHSMProfile); - } - - logger.info("KEK rotation: KMS key {} now has {} versions (active: v{}, previous: v{})", - kmsKey, newVersion.getVersionNumber(), newVersion.getVersionNumber(), - newVersion.getVersionNumber() - 1); - - // Schedule background job to rewrap all DEKs - // This will gradually rewrap wrapped keys to use the new KEK version - return newKekId; - - } catch (Exception e) { - logger.error("KEK rotation failed for kmsKey {}: {}", kmsKey, e.getMessage()); - throw handleKmsException(e); - } - } - - // ==================== DEK Operations ==================== - @Override @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_UNWRAP, eventDescription = "unwrapping volume key") public byte[] unwrapVolumeKey(WrappedKey wrappedKey, Long zoneId) throws KMSException { - validateKmsEnabled(zoneId); - - // Determine provider from wrapped key String providerName = wrappedKey.getProviderName(); KMSProvider provider = getKMSProvider(providerName); @@ -214,80 +160,22 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } } - @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "creating user KMS key") - KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, - String name, String description, KeyPurpose purpose, - Integer keyBits, long hsmProfileId) throws KMSException { - validateKmsEnabled(zoneId); - - HSMProfileVO profile = hsmProfileDao.findById(hsmProfileId); - if (profile == null) { - throw KMSException.invalidParameter("HSM Profile not found"); - } - // Validate access - if (profile.getAccountId() != null && !profile.getAccountId().equals(accountId)) { - // Check if admin - // For simplicity, strict check for now. Ideally should check if user is admin. - // Assuming caller check happened upstream in createKMSKey(CreateKMSKeyCmd) - throw KMSException.invalidParameter("HSM Profile not found"); - } - - // Determine provider from HSM profile - KMSProvider provider = getKMSProvider(profile.getProtocol()); - - // Generate unique KEK label - String kekLabel = purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); - - // Create KEK in provider - String providerKekLabel; - Long finalProfileId = hsmProfileId; - try { - providerKekLabel = retryOperation(() -> provider.createKek(purpose, kekLabel, keyBits, finalProfileId)); - } catch (Exception e) { - throw handleKmsException(e); - } - - // Create metadata entry - KMSKeyVO kmsKey = new KMSKeyVO(name, description, providerKekLabel, purpose, - accountId, domainId, zoneId, provider.getProviderName(), - "AES/GCM/NoPadding", keyBits); - kmsKey.setHsmProfileId(finalProfileId); - kmsKey = kmsKeyDao.persist(kmsKey); - - // Create initial KEK version (version 1, status=Active) - KMSKekVersionVO initialVersion = new KMSKekVersionVO(kmsKey.getId(), 1, providerKekLabel, - KMSKekVersionVO.Status.Active); - initialVersion.setHsmProfileId(finalProfileId); - initialVersion = kmsKekVersionDao.persist(initialVersion); - - logger.info("Created KMS key ({}) with initial KEK version {} for account {} in zone {} (profile: {})", - kmsKey, initialVersion.getVersionNumber(), accountId, zoneId, finalProfileId); - return kmsKey; - } - - @Override - public List listUserKMSKeys(Long accountId, Long domainId, Long zoneId, - KeyPurpose purpose, KMSKey.State state) { - return kmsKeyDao.listAccessibleKeys(accountId, domainId, zoneId, purpose, state); - } - @Override public boolean hasPermission(Long callerAccountId, KMSKey key) { - if (key == null || key.getState() == KMSKey.State.Deleted) { + if (callerAccountId == null) { return false; } - - if (key.getAccountId() == callerAccountId) { - return true; + if (key == null) { + return false; + } + if (!key.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + key); } - Account caller = accountManager.getAccount(callerAccountId); - Account owner = accountManager.getAccount(key.getAccountId()); - - if (caller == null || owner == null) { + if (caller == null) { return false; } - + Account owner = accountManager.getAccount(key.getAccountId()); try { accountManager.checkAccess(caller, null, true, owner); return true; @@ -296,63 +184,52 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } } - private void deleteUserKMSKey(KMSKeyVO key, Long callerAccountId) throws KMSException { - if (!hasPermission(callerAccountId, key)) { - throw KMSException.invalidParameter("No permission to delete KMS key: " + key.getUuid()); + @Override + public void checkKmsKeyForVolumeEncryption(Account caller, Long kmsKeyId, Long zoneId) { + if (kmsKeyId == null) { + return; } - - // Check if key is in use - - // TODO: Check if there are any volumes linked with the kms key and delete accordingly. - // The below check seems incorrect here. - long wrappedKeyCount = kmsKeyDao.countWrappedKeysByKmsKey(key.getId()); - if (wrappedKeyCount > 0) { - throw KMSException.invalidParameter("Cannot delete KMS key: " + wrappedKeyCount + - " wrapped key(s) still reference this key"); + checkKmsKeyAccess(caller, kmsKeyId); + KMSKeyVO key = kmsKeyDao.findById(kmsKeyId); + if (key.getZoneId() != null && zoneId != null && !key.getZoneId().equals(zoneId)) { + throw new InvalidParameterValueException( + "KMS key belongs to zone " + key.getZoneId() + + " but the target resource is in zone " + zoneId); } - - // Soft delete - key.setState(KMSKey.State.Deleted); - key.setRemoved(new Date()); - kmsKeyDao.update(key.getId(), key); - - logger.info("Deleted KMS key {}", key); - } - - private KMSKey updateUserKMSKey(KMSKeyVO key, Long callerAccountId, - String name, String description, KMSKey.State state) throws KMSException { - if (!hasPermission(callerAccountId, key)) { - throw KMSException.invalidParameter("No permission to update KMS key: " + key.getUuid()); + if (!key.isEnabled()) { + throw new InvalidParameterValueException( + "KMS key is not enabled and cannot be used for volume encryption: " + key.getUuid()); } - - boolean updated = false; - if (name != null && !name.equals(key.getName())) { - key.setName(name); - updated = true; + if (key.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw new InvalidParameterValueException( + "KMS key purpose must be volume encryption; key has purpose: " + key.getPurpose().getName()); } - if (description != null && !description.equals(key.getDescription())) { - key.setDescription(description); - updated = true; - } - if (state != null && state != key.getState()) { - if (state == KMSKey.State.Deleted) { - throw KMSException.invalidParameter("Cannot set state to Deleted. Use deleteKMSKey instead."); - } - key.setState(state); - updated = true; - } - - if (updated) { - kmsKeyDao.update(key.getId(), key); - logger.info("Updated KMS key {}", key); - } - - return key; } /** - * Unwrap a DEK by wrapped key ID, trying multiple KEK versions if needed + * Validate that the caller has permission to use a KMS key. + * No-op if kmsKeyId is null. + * + * @param caller the caller's account + * @param kmsKeyId the KMS key database ID + * @throws InvalidParameterValueException if key not found + * @throws PermissionDeniedException if caller lacks access */ + public void checkKmsKeyAccess(Account caller, Long kmsKeyId) { + if (kmsKeyId == null) { + return; + } + KMSKeyVO key = kmsKeyDao.findById(kmsKeyId); + checkKmsKeyAccess(caller, key); + } + + public void checkKmsKeyAccess(Account caller, KMSKeyVO key) { + if (key == null) { + throw new InvalidParameterValueException("KMS key not found"); + } + accountManager.checkAccess(caller, null, true, key); + } + @Override public byte[] unwrapKey(Long wrappedKeyId) throws KMSException { KMSWrappedKeyVO wrappedVO = kmsWrappedKeyDao.findById(wrappedKeyId); @@ -365,19 +242,11 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable throw KMSException.kekNotFound("KMS key not found for wrapped key: " + wrappedKeyId); } - // Try the specific version first if available if (wrappedVO.getKekVersionId() != null) { KMSKekVersionVO version = kmsKekVersionDao.findById(wrappedVO.getKekVersionId()); if (version != null && version.getStatus() != KMSKekVersionVO.Status.Archived) { try { - HSMProfileVO hsmProfile = hsmProfileDao.findById(version.getHsmProfileId()); - KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); - - WrappedKey wrapped = new WrappedKey(version.getKekLabel(), kmsKey.getPurpose(), - kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), - hsmProfile.getProtocol(), wrappedVO.getCreated(), kmsKey.getZoneId()); - // Pass HSM profile ID from version - byte[] dek = retryOperation(() -> provider.unwrapKey(wrapped, version.getHsmProfileId())); + byte[] dek = getUnwrappedKey(wrappedVO, kmsKey, version); logger.debug("Successfully unwrapped key {} with KEK version {}", wrappedKeyId, version.getVersionNumber()); return dek; @@ -391,14 +260,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable List versions = kmsKekVersionDao.getVersionsForDecryption(kmsKey.getId()); for (KMSKekVersionVO version : versions) { try { - HSMProfileVO hsmProfile = hsmProfileDao.findById(version.getHsmProfileId()); - KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); - - WrappedKey wrapped = new WrappedKey(version.getKekLabel(), kmsKey.getPurpose(), - kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), - kmsKey.getProviderName(), wrappedVO.getCreated(), kmsKey.getZoneId()); - // Pass HSM profile ID from version - byte[] dek = retryOperation(() -> provider.unwrapKey(wrapped, version.getHsmProfileId())); + byte[] dek = getUnwrappedKey(wrappedVO, kmsKey, version); logger.info("Successfully unwrapped key {} with KEK version {} (fallback)", wrappedKeyId, version.getVersionNumber()); return dek; @@ -410,18 +272,26 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable throw KMSException.wrapUnwrapFailed("Failed to unwrap key with any available KEK version"); } - // ==================== Lifecycle Methods ==================== + private byte[] getUnwrappedKey(KMSWrappedKeyVO wrappedVO, KMSKeyVO kmsKey, + KMSKekVersionVO version) throws Exception { + HSMProfileVO hsmProfile = hsmProfileDao.findById(version.getHsmProfileId()); + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + + WrappedKey wrapped = new WrappedKey(wrappedVO.getUuid(), version.getKekLabel(), kmsKey.getPurpose(), + kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), + hsmProfile.getProtocol(), wrappedVO.getCreated(), kmsKey.getZoneId()); + return retryOperation(() -> provider.unwrapKey(wrapped, version.getHsmProfileId())); + } @Override @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_WRAP, - eventDescription = "generating volume key with specified KEK") + eventDescription = "generating volume key with specified KEK") public WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException { - // Get and validate KMS key if (kmsKey == null) { throw KMSException.kekNotFound("KMS key not found"); } - if (kmsKey.getState() != KMSKey.State.Enabled) { + if (!kmsKey.isEnabled()) { throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey); } @@ -429,28 +299,28 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kmsKey); } - // Get active KEK version KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); HSMProfileVO hsmProfile = hsmProfileDao.findById(activeVersion.getHsmProfileId()); if (hsmProfile == null) { throw KMSException.invalidParameter("HSM profile not found: " + activeVersion.getHsmProfileId()); } + if (!hsmProfile.isEnabled()) { + throw KMSException.invalidParameter("HSM profile is not enabled: " + hsmProfile.getName()); + } KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); - // Generate and wrap DEK using active KEK version int dekSize = KMSDekSizeBits.value(); WrappedKey wrappedKey; try { - wrappedKey = retryOperation(() -> - provider.generateAndWrapDek(KeyPurpose.VOLUME_ENCRYPTION, activeVersion.getKekLabel(), dekSize, activeVersion.getHsmProfileId())); - // Store the wrapped key in database + wrappedKey = retryOperation(() -> provider.generateAndWrapDek(KeyPurpose.VOLUME_ENCRYPTION, + activeVersion.getKekLabel(), dekSize, + activeVersion.getHsmProfileId())); KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO(kmsKey.getId(), activeVersion.getId(), kmsKey.getZoneId(), wrappedKey.getWrappedKeyMaterial()); wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); - // Return WrappedKey with database UUID so it can be looked up later - // Note: Volume creation code should look up by UUID and set volume.kmsWrappedKeyId + // Volume creation code looks up by UUID and sets volume.kmsWrappedKeyId wrappedKey = new WrappedKey( wrappedKeyVO.getUuid(), wrappedKey.getKekId(), @@ -459,8 +329,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable wrappedKey.getWrappedKeyMaterial(), wrappedKey.getProviderName(), wrappedKey.getCreated(), - wrappedKey.getZoneId() - ); + wrappedKey.getZoneId()); } catch (Exception e) { throw handleKmsException(e); } @@ -470,9 +339,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return wrappedKey; } - /** - * Get the active KEK version for a KMS key - */ private KMSKekVersionVO getActiveKekVersion(Long kmsKeyId) throws KMSException { KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(kmsKeyId); if (activeVersion == null) { @@ -481,49 +347,25 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return activeVersion; } - // ==================== Configurable Implementation ==================== - @Override public KMSKeyResponse createKMSKey(CreateKMSKeyCmd cmd) throws KMSException { Account caller = CallContext.current().getCallingAccount(); - Account targetAccount = caller; + Account targetAccount = accountManager.finalizeOwner(caller, cmd.getAccountName(), cmd.getDomainId(), + cmd.getProjectId()); - if (cmd.getAccountName() != null || cmd.getDomainId() != null) { - // Only admins and domain admins can create keys for other accounts - if (!accountManager.isAdmin(caller.getId()) && - !accountManager.isDomainAdmin(caller.getId())) { - throw new ServerApiException(ApiErrorCode.UNAUTHORIZED, - "Only admins and domain admins can create keys for other accounts"); - } + KeyPurpose keyPurpose = parseKeyPurpose(cmd.getPurpose()); - if (cmd.getAccountName() != null && cmd.getDomainId() != null) { - targetAccount = accountManager.getActiveAccountByName(cmd.getAccountName(), cmd.getDomainId()); - if (targetAccount == null) { - throw KMSException.invalidParameter( - "Unable to find account " + cmd.getAccountName() + " in domain " + cmd.getDomainId()); - } - accountManager.checkAccess(caller, null, true, targetAccount); - } else { - throw KMSException.invalidParameter("Both accountName and domainId must be specified together"); - } - } - - // Validate purpose - KeyPurpose keyPurpose; - try { - keyPurpose = KeyPurpose.fromString(cmd.getPurpose()); - } catch (IllegalArgumentException e) { - throw KMSException.invalidParameter("Invalid purpose: " + cmd.getPurpose() + - ". Valid values: volume, tls"); - } - - // Validate key bits int bits = cmd.getKeyBits(); if (bits != 128 && bits != 192 && bits != 256) { - throw KMSException.invalidParameter("Key bits must be 128, 192, or 256"); + throw new InvalidParameterValueException("Key bits must be 128, 192, or 256"); + } + + HSMProfileVO profile = getHSMProfile(cmd.getHsmProfileId()); + checkHSMProfileAccess(caller, profile, false); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); } - // Create the KMS key KMSKey kmsKey = createUserKMSKey( targetAccount.getId(), targetAccount.getDomainId(), @@ -532,172 +374,246 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable cmd.getDescription(), keyPurpose, bits, - cmd.getHsmProfileId() - ); + cmd.getHsmProfileId()); - return responseGenerator.createKMSKeyResponse(kmsKey); + return createKMSKeyResponse(kmsKey); } - // ==================== KEK Version Management ==================== + KMSKeyResponse createKMSKeyResponse(KMSKey kmsKey) { + KMSKeyResponse response = new KMSKeyResponse(); + response.setId(kmsKey.getUuid()); + response.setName(kmsKey.getName()); + response.setDescription(kmsKey.getDescription()); + response.setPurpose(kmsKey.getPurpose().getName()); + response.setAlgorithm(kmsKey.getAlgorithm()); + response.setKeyBits(kmsKey.getKeyBits()); + response.setEnabled(kmsKey.isEnabled()); + response.setCreated(kmsKey.getCreated()); + + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(kmsKey.getId()); + if (activeVersion != null) { + response.setVersion(activeVersion.getVersionNumber()); + } + + HSMProfileVO hsmProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + if (hsmProfile != null) { + response.setHsmProfileId(hsmProfile.getUuid()); + response.setHsmProfileName(hsmProfile.getName()); + } + + ApiResponseHelper.populateOwner(response, kmsKey); + + DataCenter zone = ApiDBUtils.findZoneById(kmsKey.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + Account caller = CallContext.current().getCallingAccount(); + if (caller != null && (caller.getType() == Account.Type.ADMIN + || caller.getType() == Account.Type.RESOURCE_DOMAIN_ADMIN)) { + response.setKekLabel(kmsKey.getKekLabel()); + } + + response.setObjectName("kmskey"); + return response; + } + + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_CREATE, eventDescription = "creating user KMS key") + KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, + String name, String description, KeyPurpose purpose, + Integer keyBits, long hsmProfileId) throws KMSException { + HSMProfileVO profile = hsmProfileDao.findById(hsmProfileId); + if (profile == null) { + throw KMSException.invalidParameter("HSM Profile not found"); + } + + KMSProvider provider = getKMSProvider(profile.getProtocol()); + String kekLabel = purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); + + String providerKekLabel; + Long finalProfileId = hsmProfileId; + try { + providerKekLabel = retryOperation(() -> provider.createKek(purpose, kekLabel, keyBits, finalProfileId)); + } catch (Exception e) { + throw handleKmsException(e); + } + + KMSKeyVO kmsKey = new KMSKeyVO(name, description, providerKekLabel, purpose, + accountId, domainId, zoneId, "AES/GCM/NoPadding", keyBits); + kmsKey.setHsmProfileId(finalProfileId); + kmsKey = kmsKeyDao.persist(kmsKey); + + KMSKekVersionVO initialVersion = new KMSKekVersionVO(kmsKey.getId(), 1, providerKekLabel, + KMSKekVersionVO.Status.Active); + initialVersion.setHsmProfileId(finalProfileId); + initialVersion = kmsKekVersionDao.persist(initialVersion); + + logger.info("Created KMS key ({}) with initial KEK version {} for account {} in zone {} (profile: {})", + kmsKey, initialVersion.getVersionNumber(), accountId, zoneId, finalProfileId); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), kmsKey.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_KMS_KEY_CREATE, + String.format("Created KMS key: %s", kmsKey.getUuid()), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + return kmsKey; + } @Override public ListResponse listKMSKeys(ListKMSKeysCmd cmd) { Account caller = CallContext.current().getCallingAccount(); - if (caller == null) { - return createEmptyListResponse(); - } - KeyPurpose keyPurpose = null; - if (cmd.getPurpose() != null) { - try { - keyPurpose = KeyPurpose.fromString(cmd.getPurpose()); - } catch (IllegalArgumentException e) { - throw KMSException.invalidParameter("Invalid purpose: " + cmd.getPurpose() + ". Valid values: volume, tls"); - } - } + List permittedAccounts = new ArrayList<>(); + Ternary domainIdRecursiveListProject = new Ternary<>( + cmd.getDomainId(), cmd.isRecursive(), null); + accountManager.buildACLSearchParameters(caller, cmd.getId(), cmd.getAccountName(), + cmd.getProjectId(), permittedAccounts, domainIdRecursiveListProject, + cmd.listAll(), false); + Long domainId = domainIdRecursiveListProject.first(); + Boolean isRecursive = domainIdRecursiveListProject.second(); + ListProjectResourcesCriteria listProjectResourcesCriteria = domainIdRecursiveListProject.third(); - KMSKey.State keyState = null; - if (cmd.getState() != null) { - keyState = EnumUtils.getEnumIgnoreCase(KMSKey.State.class, cmd.getState()); - if (keyState == null) { - throw KMSException.invalidParameter("Invalid state: " + cmd.getState() + ". Valid values: Enabled, Disabled"); - } - } + SearchBuilder sb = getSearchBuilderForKMSKeys(domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + SearchCriteria sc = getSearchCriteriaForKMSKeys(sb, cmd, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); - if (cmd.getId() != null) { - KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); - if (key == null || key.getState() == KMSKey.State.Deleted) { - return createEmptyListResponse(); - } - - if (hasPermission(caller.getId(), key)) { - List responses = new ArrayList<>(); - responses.add(responseGenerator.createKMSKeyResponse(key)); - ListResponse listResponse = new ListResponse<>(); - listResponse.setResponses(responses, responses.size()); - return listResponse; - } - return createEmptyListResponse(); - } - - List keys = listUserKMSKeys( - caller.getId(), - caller.getDomainId(), - cmd.getZoneId(), - keyPurpose, - keyState - ); + Filter searchFilter = new Filter(KMSKeyVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); + Pair, Integer> result = kmsKeyDao.searchAndCount(sc, searchFilter); + List keys = result.first(); + Integer count = result.second(); List responses = new ArrayList<>(); for (KMSKey key : keys) { - responses.add(responseGenerator.createKMSKeyResponse(key)); + responses.add(createKMSKeyResponse(key)); } ListResponse listResponse = new ListResponse<>(); - listResponse.setResponses(responses, responses.size()); + listResponse.setResponses(responses, count); return listResponse; } + SearchBuilder getSearchBuilderForKMSKeys(Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchBuilder sb = kmsKeyDao.createSearchBuilder(); + accountManager.buildACLSearchBuilder(sb, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("zoneId", sb.entity().getZoneId(), SearchCriteria.Op.EQ); + sb.and("purpose", sb.entity().getPurpose(), SearchCriteria.Op.EQ); + sb.and("enabled", sb.entity().isEnabled(), SearchCriteria.Op.EQ); + sb.and("hsmProfileId", sb.entity().getHsmProfileId(), SearchCriteria.Op.EQ); + sb.done(); + return sb; + } + + SearchCriteria getSearchCriteriaForKMSKeys(SearchBuilder searchBuilder, ListKMSKeysCmd cmd, + Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchCriteria sc = searchBuilder.create(); + accountManager.buildACLSearchCriteria(sc, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + KeyPurpose keyPurpose = parseKeyPurpose(cmd.getPurpose()); + if (cmd.getId() != null) { + sc.setParameters("id", cmd.getId()); + } + if (cmd.getZoneId() != null) { + sc.setParameters("zoneId", cmd.getZoneId()); + } + if (keyPurpose != null) { + sc.setParameters("purpose", keyPurpose); + } + if (cmd.getEnabled() != null) { + sc.setParameters("enabled", cmd.getEnabled()); + } + if (cmd.getHsmProfileId() != null) { + sc.setParameters("hsmProfileId", cmd.getHsmProfileId()); + } + return sc; + } + @Override public KMSKeyResponse updateKMSKey(UpdateKMSKeyCmd cmd) throws KMSException { - Long callerAccountId = CallContext.current().getCallingAccount().getId(); + Account caller = CallContext.current().getCallingAccount(); + KMSKeyVO key = findKMSKeyAndCheckAccess(cmd.getId(), caller); + KMSKey updatedKey = updateUserKMSKey(key, cmd.getName(), cmd.getDescription(), cmd.getEnabled()); + return createKMSKeyResponse(updatedKey); + } - KMSKey.State keyState = null; - if (cmd.getState() != null) { - keyState = EnumUtils.getEnumIgnoreCase(KMSKey.State.class, cmd.getState()); - if (keyState == KMSKey.State.Deleted) { - throw KMSException.invalidParameter("Cannot set state to Deleted. Use deleteKMSKey instead."); - } - if (keyState == null) { - throw KMSException.invalidParameter("Invalid state: " + cmd.getState() + ". Valid values: Enabled, Disabled"); - } + private KMSKey updateUserKMSKey(KMSKeyVO key, String name, String description, Boolean enabled) { + boolean updated = false; + if (name != null && !name.equals(key.getName())) { + key.setName(name); + updated = true; + } + if (description != null && !description.equals(key.getDescription())) { + key.setDescription(description); + updated = true; + } + if (enabled != null && enabled != key.isEnabled()) { + key.setEnabled(enabled); + updated = true; } - KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); - if (key == null) { - throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); + if (updated) { + kmsKeyDao.update(key.getId(), key); + logger.info("Updated KMS key {}", key); } - KMSKey updatedKey = updateUserKMSKey(key, callerAccountId, - cmd.getName(), cmd.getDescription(), keyState); - return responseGenerator.createKMSKeyResponse(updatedKey); + return key; } @Override public SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException { - Long callerAccountId = CallContext.current().getCallingAccount().getId(); + Account caller = CallContext.current().getCallingAccount(); - KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); - if (key == null) { - throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); - } + KMSKeyVO key = findKMSKeyAndCheckAccess(cmd.getId(), caller); - deleteUserKMSKey(key, callerAccountId); + deleteUserKMSKey(key, caller); return new SuccessResponse(); } - // ==================== User KEK Management ==================== - - /** - * Create a new KEK version for a KMS key - */ - private KMSKekVersionVO createKekVersion(Long kmsKeyId, String kekLabel, Long hsmProfileId) throws KMSException { - // Get existing versions to determine next version number - List existingVersions = kmsKekVersionDao.listByKmsKeyId(kmsKeyId); - int nextVersion = existingVersions.stream() - .mapToInt(KMSKekVersionVO::getVersionNumber) - .max() - .orElse(0) + 1; - - // Mark current active version as Previous - KMSKekVersionVO currentActive = kmsKekVersionDao.getActiveVersion(kmsKeyId); - if (currentActive != null) { - currentActive.setStatus(KMSKekVersionVO.Status.Previous); - kmsKekVersionDao.update(currentActive.getId(), currentActive); + private void deleteUserKMSKey(KMSKeyVO key, Account caller) throws KMSException { + long wrappedKeyCount = kmsWrappedKeyDao.countByKmsKeyId(key.getId()); + if (wrappedKeyCount > 0) { + throw new InvalidParameterValueException("Cannot delete KMS key: " + key + ". " + wrappedKeyCount + + " wrapped key(s) still reference this key"); } - // Create new active version - KMSKekVersionVO newVersion = new KMSKekVersionVO(kmsKeyId, nextVersion, kekLabel, - KMSKekVersionVO.Status.Active); - newVersion.setHsmProfileId(hsmProfileId); - newVersion = kmsKekVersionDao.persist(newVersion); - - logger.info("Created KEK version {} for KMS key {} (label: {}, profile: {})", nextVersion, kmsKeyId, kekLabel, hsmProfileId); - return newVersion; + kmsKeyDao.remove(key.getId()); + if (volumeDao.existsWithKmsKey(key.getId())) { + throw new InvalidParameterValueException("Cannot delete KMS key: " + key + ". " + + "There are Volumes which still reference this key"); + } + checkKmsKeyAccess(caller, key); + logger.info("Deleted KMS key {}", key); } - // ==================== Admin Operations ==================== - @Override - @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_ROTATE, eventDescription = "rotating KMS key", async = true) + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_CREATE, eventDescription = "rotating KMS key", async = true) public String rotateKMSKey(RotateKMSKeyCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); Integer keyBits = cmd.getKeyBits(); - String hsmProfileName = cmd.getHsmProfile(); + Long hsmProfileId = cmd.getHsmProfileId(); - KMSKeyVO kmsKey = kmsKeyDao.findById(cmd.getId()); - if (kmsKey == null) { - throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); + KMSKeyVO kmsKey = findKMSKeyAndCheckAccess(cmd.getId(), caller); + + if (!kmsKey.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + kmsKey); } - if (kmsKey.getState() != KMSKey.State.Enabled) { - throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey); - } - - // Validate and resolve target HSM profile if provided HSMProfileVO profile = null; - if (hsmProfileName != null) { - profile = hsmProfileDao.findByName(hsmProfileName); + if (hsmProfileId != null) { + profile = hsmProfileDao.findById(hsmProfileId); if (profile == null) { - throw KMSException.invalidParameter("Target HSM Profile not found: " + hsmProfileName); + throw new InvalidParameterValueException("Target HSM Profile not found: " + hsmProfileId); } - // Check access (assuming admin caller since rotate is admin command, but good to check scoping) - if (profile.getAccountId() != null && !profile.getAccountId().equals(kmsKey.getAccountId())) { - // Warn or fail - admin can migrate to any profile really, but key owner should have access ideally. - // For now allow admin to do anything. + checkHSMProfileAccess(caller, profile, false); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); } } - // Get current active version to determine key bits if not provided int newKeyBits = keyBits != null ? keyBits : kmsKey.getKeyBits(); KMSKekVersionVO currentActive = getActiveKekVersion(kmsKey.getId()); @@ -706,122 +622,167 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable currentActive.getKekLabel(), null, // auto-generate new label newKeyBits, - profile - ); + profile); KMSKekVersionVO newVersion = getActiveKekVersion(kmsKey.getId()); logger.info("KMS key rotation initiated: {} -> new KEK version {} (UUID: {}). " + - "Background job will gradually rewrap {} wrapped key(s)", + "Background job will gradually rewrap {} wrapped key(s)", kmsKey, newVersion.getVersionNumber(), newVersion.getUuid(), kmsWrappedKeyDao.countByKmsKeyId(kmsKey.getId())); - // Background KMSRewrapWorker will automatically detect Previous versions - // and gradually rewrap wrapped keys in batches + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), kmsKey.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_KMS_KEY_CREATE, + String.format("KMS key rotation completed for KMS key from version %d to version %d", + currentActive.getVersionNumber(), newVersion.getVersionNumber()), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); return newVersion.getUuid(); } - /** - * Helper method to rewrap a single wrapped key with a new KEK version. - * Unwraps the key, re-wraps it with the new KEK, and updates the database. - * - * @param wrappedKeyVO the wrapped key to rewrap - * @param kmsKey the KMS key - * @param newVersion the new KEK version to wrap with - * @param provider the KMS provider - */ - void rewrapSingleKey(KMSWrappedKeyVO wrappedKeyVO, KMSKeyVO kmsKey, - KMSKekVersionVO newVersion, KMSProvider provider) { - byte[] dek = null; + String rotateKek(KMSKeyVO kmsKey, String oldKekLabel, String newKekLabel, int keyBits, + HSMProfileVO newHSMProfile) throws KMSException { + if (StringUtils.isEmpty(oldKekLabel)) { + throw KMSException.invalidParameter("oldKekLabel must be specified"); + } + + if (newHSMProfile == null) { + newHSMProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + } + + KMSProvider provider = getKMSProvider(newHSMProfile.getProtocol()); + try { - // Unwrap with current/old version - // This now handles looking up the correct profile for the OLD key inside unwrapKey() via version lookup - dek = unwrapKey(wrappedKeyVO.getId()); + logger.info("Starting KEK rotation from {} to {} for kms key {}", oldKekLabel, newKekLabel, kmsKey); - // Wrap the existing DEK with new KEK version - // Pass the target profile ID if available - WrappedKey newWrapped = provider.wrapKey( - dek, - kmsKey.getPurpose(), - newVersion.getKekLabel(), - newVersion.getHsmProfileId() - ); - - wrappedKeyVO.setKekVersionId(newVersion.getId()); - wrappedKeyVO.setWrappedBlob(newWrapped.getWrappedKeyMaterial()); - kmsWrappedKeyDao.update(wrappedKeyVO.getId(), wrappedKeyVO); - } finally { - // Always zeroize DEK from memory - if (dek != null) { - Arrays.fill(dek, (byte) 0); + if (StringUtils.isEmpty(newKekLabel)) { + newKekLabel = kmsKey.getPurpose().getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); } + + String finalNewKekLabel = newKekLabel; + Long newProfileId = newHSMProfile.getId(); + String newKekId = retryOperation( + () -> provider.createKek(kmsKey.getPurpose(), finalNewKekLabel, keyBits, newProfileId)); + + KMSKekVersionVO newVersion = createKekVersion(kmsKey.getId(), newKekId, newProfileId); + + if (!newProfileId.equals(kmsKey.getHsmProfileId())) { + kmsKey.setHsmProfileId(newProfileId); + kmsKeyDao.update(kmsKey.getId(), kmsKey); + logger.info("Updated KMS key {} to use HSM profile {}", kmsKey, newHSMProfile); + } + + logger.info("KEK rotation: KMS key {} now has {} versions (active: v{}, previous: v{})", + kmsKey, newVersion.getVersionNumber(), newVersion.getVersionNumber(), + newVersion.getVersionNumber() - 1); + + return newKekId; + + } catch (Exception e) { + logger.error("KEK rotation failed for kmsKey {}: {}", kmsKey, e.getMessage()); + throw handleKmsException(e); } } + private KMSKekVersionVO createKekVersion(Long kmsKeyId, String kekLabel, Long hsmProfileId) throws KMSException { + List existingVersions = kmsKekVersionDao.listByKmsKeyId(kmsKeyId); + int nextVersion = existingVersions.stream() + .mapToInt(KMSKekVersionVO::getVersionNumber) + .max() + .orElse(0) + 1; + + KMSKekVersionVO currentActive = kmsKekVersionDao.getActiveVersion(kmsKeyId); + if (currentActive != null) { + currentActive.setStatus(KMSKekVersionVO.Status.Previous); + kmsKekVersionDao.update(currentActive.getId(), currentActive); + } + + KMSKekVersionVO newVersion = new KMSKekVersionVO(kmsKeyId, nextVersion, kekLabel, + KMSKekVersionVO.Status.Active); + newVersion.setHsmProfileId(hsmProfileId); + newVersion = kmsKekVersionDao.persist(newVersion); + + logger.info("Created KEK version {} for KMS key {} (label: {}, profile: {})", nextVersion, kmsKeyId, kekLabel, + hsmProfileId); + return newVersion; + } + @Override - @ActionEvent(eventType = EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS, eventDescription = "migrating volumes to KMS", async = true) + @ActionEvent(eventType = EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS, + eventDescription = "migrating volumes to KMS", + async = true) public int migrateVolumesToKMS(MigrateVolumesToKMSCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); Long zoneId = cmd.getZoneId(); String accountName = cmd.getAccountName(); Long domainId = cmd.getDomainId(); Long kmsKeyId = cmd.getKmsKeyId(); + List volumeIds = cmd.getVolumeIds(); - if (zoneId == null) { - throw KMSException.invalidParameter("zoneId must be specified"); + if (zoneId == null && CollectionUtils.isEmpty(volumeIds)) { + throw new InvalidParameterValueException("Need to specify either ZoneId or Volume IDs"); + } + + if (zoneId != null && CollectionUtils.isNotEmpty(volumeIds)) { + throw new InvalidParameterValueException("Specify either ZoneId or Volume IDs"); } if (kmsKeyId == null) { - throw KMSException.invalidParameter("kmsKeyId must be specified"); + throw new InvalidParameterValueException("kmsKeyId must be specified"); } - validateKmsEnabled(zoneId); - - // Get and validate KMS key KMSKeyVO kmsKey = kmsKeyDao.findById(kmsKeyId); if (kmsKey == null) { - throw KMSException.kekNotFound("KMS key not found: " + kmsKeyId); + throw new InvalidParameterValueException("KMS key not found: " + kmsKeyId); } + checkKmsKeyAccess(caller, kmsKey); - if (kmsKey.getState() != KMSKey.State.Enabled) { - throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey.getUuid()); + if (!kmsKey.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + kmsKey.getUuid()); } if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { - throw KMSException.invalidParameter("KMS key purpose must be VOLUME_ENCRYPTION"); + throw new InvalidParameterValueException("KMS key purpose must be VOLUME_ENCRYPTION"); } - // Get provider from KMS key's HSM profile KMSProvider provider; if (kmsKey.getHsmProfileId() != null) { - HSMProfileVO profile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); - if (profile == null) { - throw KMSException.invalidParameter("HSM Profile not found for KMS key"); + HSMProfileVO profile = getHSMProfile(kmsKey.getHsmProfileId()); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); } provider = getKMSProvider(profile.getProtocol()); } else { provider = getKMSProvider("database"); } - // Get active KEK version KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); Long accountId = null; if (accountName != null) { - accountId = accountManager.finalyzeAccountId(accountName, domainId, null, true); + accountId = accountManager.finalizeAccountId(accountName, domainId, null, true); } - int pageSize = 100; // Process 100 volumes per page to avoid OutOfMemoryError + int pageSize = 100; int successCount = 0; int failureCount = 0; + int totalCount; logger.info("Starting migration of volumes to KMS (zone: {}, account: {}, domain: {})", zoneId, accountId, domainId); - Pair, Integer> volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, domainId, pageSize); - List volumes = volumeListPair.first(); - int totalCount = volumeListPair.second(); - + List volumes; + if (CollectionUtils.isNotEmpty(volumeIds)) { + volumes = volumeDao.listByIds(volumeIds); + accountManager.checkAccess(caller, null, true, volumes.toArray(new Volume[0])); + totalCount = volumes.size(); + } else { + Pair, Integer> volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, + domainId, + pageSize); + volumes = volumeListPair.first(); + totalCount = volumeListPair.second(); + } while (true) { if (CollectionUtils.isEmpty(volumes) || totalCount == 0) { @@ -830,64 +791,29 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable for (VolumeVO volume : volumes) { try { - // Load passphrase - PassphraseVO passphrase = passphraseDao.findById(volume.getPassphraseId()); - if (passphrase == null) { - logger.warn("Passphrase not found for volume {}: {}", volume.getId(), volume.getPassphraseId()); - failureCount++; - continue; + if (migrateVolumeToKmsKey(provider, volume, kmsKey, activeVersion)) { + successCount++; + logger.debug("Migrated volume's encryption {} to KMS (batch {})", volume, kmsKey); } - - // Get passphrase bytes - // Note: PassphraseVO.getPassphrase() returns Base64-encoded bytes - // This is consistent with how hypervisors (KVM/QEMU) expect the key format - // The KMS will store the same format, maintaining compatibility - byte[] passphraseBytes = passphrase.getPassphrase(); - - // Wrap existing passphrase bytes as DEK using the specified KMS key - // Pass the HSM profile ID from the active version - WrappedKey wrappedKey = provider.wrapKey( - passphraseBytes, - KeyPurpose.VOLUME_ENCRYPTION, - activeVersion.getKekLabel(), - activeVersion.getHsmProfileId() - ); - - // Store wrapped key - KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO( - kmsKey.getId(), - activeVersion.getId(), - zoneId, - wrappedKey.getWrappedKeyMaterial() - ); - wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); - - // Update volume - volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); - volume.setKmsKeyId(kmsKey.getId()); - volume.setPassphraseId(null); // Clear passphrase reference - volumeDao.update(volume.getId(), volume); - - // zeroize passphrase bytes - if (passphraseBytes != null) { - Arrays.fill(passphraseBytes, (byte) 0); - } - - successCount++; - logger.debug("Migrated volume's encryption {} to KMS (batch {})", volume, kmsKey); } catch (Exception e) { failureCount++; logger.warn("Failed to migrate volume {}: {}", volume.getId(), e.getMessage()); - // Continue with next volume } } + logger.debug("Processed {} volumes. success: {}, failure: {}, total: {}", volumes.size(), + successCount, failureCount, totalCount); - logger.debug("Processed {} volumes. success: {}, failure: {}", volumes.size(), - successCount, failureCount); - volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, domainId, pageSize); + if (CollectionUtils.isNotEmpty(volumeIds)) { + break; + } + + Pair, Integer> volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, + domainId, pageSize); volumes = volumeListPair.first(); if (totalCount == volumeListPair.second()) { - logger.debug("{} volumes pending for migration because passphrase was not found or migration failed", totalCount); + logger.debug( + "{} volumes pending for migration because passphrase was not found or migration failed", + totalCount); break; } totalCount = volumeListPair.second(); @@ -898,227 +824,44 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return successCount; } - private void validateKmsEnabled(Long zoneId) throws KMSException { - if (zoneId == null) { - throw KMSException.invalidParameter("Zone ID cannot be null"); + private boolean migrateVolumeToKmsKey(KMSProvider provider, VolumeVO volume, KMSKey kmsKey, + KMSKekVersionVO activeVersion) { + PassphraseVO passphrase = passphraseDao.findById(volume.getPassphraseId()); + if (passphrase == null) { + logger.warn( + "Skipping migration of volume from to the KMS key {} because passphrase id: {} not found for " + + "volume {}}", + kmsKey, volume.getPassphraseId(), volume); + return false; } - if (!isKmsEnabled(zoneId)) { - throw KMSException.providerNotInitialized( - "KMS is not enabled for zone " + zoneId + ". Set kms.enabled=true for this zone."); - } - } - - private T retryOperation(KmsOperation operation) throws Exception { - int maxRetries = KMSRetryCount.value(); - int retryDelay = KMSRetryDelayMs.value(); - - Exception lastException = null; - - for (int attempt = 0; attempt <= maxRetries; attempt++) { - try { - return operation.execute(); - } catch (Exception e) { - lastException = e; - - // Check if retryable - if (e instanceof KMSException && !((KMSException) e).isRetryable()) { - throw e; - } - - if (attempt < maxRetries) { - logger.warn("KMS operation failed (attempt {}/{}): {}. Retrying...", - attempt + 1, maxRetries + 1, e.getMessage()); - - try { - Thread.sleep((long) retryDelay * (attempt + 1)); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new CloudRuntimeException("Interrupted during retry", ie); - } - } else { - logger.error("KMS operation failed after {} attempts", maxRetries + 1); - } - } - } - - if (lastException != null) { - throw lastException; - } - - throw new CloudRuntimeException("KMS operation failed with no exception details"); - } - - private KMSException handleKmsException(Exception e) { - if (e instanceof KMSException) { - return (KMSException) e; - } - return KMSException.transientError("KMS operation failed: " + e.getMessage(), e); - } - - public void setKmsProviders(List kmsProviders) { - this.kmsProviders = kmsProviders; - initializeKmsProviderMap(); - } - - // ==================== API Response Methods ==================== - - /** - * Helper method to create an empty list response - */ - private ListResponse createEmptyListResponse() { - ListResponse response = new ListResponse<>(); - response.setResponses(new ArrayList<>(), 0); - return response; - } - - private void initializeKmsProviderMap() { - if (kmsProviders == null) { - return; - } - kmsProviderMap.clear(); - for (KMSProvider provider : kmsProviders) { - if (provider != null) { - kmsProviderMap.put(provider.getProviderName().toLowerCase(), provider); - logger.info("Registered KMS provider: {}", provider.getProviderName()); - } - } - } - - @Override - public boolean start() { - super.start(); - initializeKmsProviderMap(); - - // Run health check on all registered providers - for (KMSProvider provider : kmsProviderMap.values()) { - if (provider != null) { - try { - boolean healthy = provider.healthCheck(); - if (healthy) { - logger.info("KMS provider {} health check passed", provider.getProviderName()); - } else { - logger.warn("KMS provider {} health check failed", provider.getProviderName()); - } - } catch (Exception e) { - logger.warn("KMS provider {} health check error: {}", provider.getProviderName(), e.getMessage()); - } - } - } - - // Schedule background rewrap worker - scheduleRewrapWorker(); - - return true; - } - - /** - * Schedule the background KEK rewrap worker - */ - private void scheduleRewrapWorker() { - final ManagedContextTimerTask rewrapTask = new ManagedContextTimerTask() { - @Override - protected void runInContext() { - try { - processRewrapBatch(); - } catch (final Exception e) { - logger.error("Error while running KMS rewrap worker", e); - } - } - }; - - long intervalMs = KMSRewrapIntervalMs.value(); - Timer rewrapTimer = new Timer("KMSRewrapWorker"); - rewrapTimer.schedule(rewrapTask, 10000L, intervalMs); // Start after 10 seconds, run at configured interval - logger.info("KMS rewrap worker scheduled with interval: {} ms", intervalMs); - } - - /** - * Background worker method that processes KEK rewrap batches. - * Finds KEK versions marked as Previous and gradually rewraps wrapped keys - * using the active version. - */ - private void processRewrapBatch() { + // PassphraseVO.getPassphrase() returns Base64-encoded bytes matching KVM/QEMU + // format + byte[] passphraseBytes = passphrase.getPassphrase(); try { - // Find all KEK versions marked as Previous (rotation in progress) - List previousVersions = kmsKekVersionDao.findByStatus(KMSKekVersionVO.Status.Previous); + WrappedKey wrappedKey = provider.wrapKey( + passphraseBytes, + KeyPurpose.VOLUME_ENCRYPTION, + activeVersion.getKekLabel(), + activeVersion.getHsmProfileId()); - if (previousVersions.isEmpty()) { - logger.trace("No KEK versions pending rewrap"); - return; - } + KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO( + kmsKey.getId(), + activeVersion.getId(), + volume.getDataCenterId(), + wrappedKey.getWrappedKeyMaterial()); + wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); - logger.debug("Found {} KEK version(s) with status Previous - processing rewrap batches", previousVersions.size()); - - int batchSize = KMSRewrapBatchSize.value(); - - for (KMSKekVersionVO oldVersion : previousVersions) { - try { - processVersionRewrap(oldVersion, batchSize); - } catch (Exception e) { - logger.error("Error processing rewrap for KEK version {}: {}", oldVersion, e.getMessage(), e); - // Continue with next version - } - } - } catch (Exception e) { - logger.error("Error in rewrap worker: {}", e.getMessage(), e); - } - } - - /** - * Process rewrap for a single KEK version (used by background worker) - */ - private void processVersionRewrap(KMSKekVersionVO oldVersion, int batchSize) throws KMSException { - KMSKeyVO kmsKey = kmsKeyDao.findById(oldVersion.getKmsKeyId()); - if (kmsKey == null) { - logger.warn("KMS key not found for KEK version {}, skipping", oldVersion); - return; - } - - // Get active version for this KMS key - KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(oldVersion.getKmsKeyId()); - if (activeVersion == null) { - logger.warn("No active KEK version found for KMS key {}, skipping", kmsKey); - return; - } - - // Query wrapped keys still using the old version (limited to batch size) - List keysToRewrap = kmsWrappedKeyDao.listByKekVersionId(oldVersion.getId(), batchSize); - - if (keysToRewrap.isEmpty()) { - // All keys rewrapped - archive the old version - logger.info("All wrapped keys rewrapped for KEK version {} (v{}) - archiving", - oldVersion.getUuid(), oldVersion.getVersionNumber()); - - oldVersion.setStatus(KMSKekVersionVO.Status.Archived); - kmsKekVersionDao.update(oldVersion.getId(), oldVersion); - - return; - } - - // Get provider - HSMProfileVO hsmProfile = hsmProfileDao.findById(activeVersion.getHsmProfileId()); - KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); - - // Rewrap this batch using the common helper - int successCount = 0; - int failureCount = 0; - - for (KMSWrappedKeyVO wrappedKeyVO : keysToRewrap) { - try { - rewrapSingleKey(wrappedKeyVO, kmsKey, activeVersion, provider); - successCount++; - } catch (Exception e) { - failureCount++; - logger.warn("Failed to rewrap key {} for KMS key {}: {}", - wrappedKeyVO.getId(), kmsKey, e.getMessage()); - // Continue with next key - will retry in next run + volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); + volume.setKmsKeyId(kmsKey.getId()); + volume.setPassphraseId(null); + volumeDao.update(volume.getId(), volume); + return true; + } finally { + if (passphraseBytes != null) { + Arrays.fill(passphraseBytes, (byte) 0); } } - - logger.info("Rewrapped batch for KMS key {} (KEK v{} -> v{}): {} success, {} failures", - kmsKey, oldVersion.getVersionNumber(), activeVersion.getVersionNumber(), - successCount, failureCount); } @Override @@ -1129,7 +872,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } try { - // List all KMS keys owned by this account List accountKeys = kmsKeyDao.listByAccount(accountId, null, null); if (accountKeys == null || accountKeys.isEmpty()) { @@ -1142,7 +884,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable boolean allDeleted = true; for (KMSKeyVO key : accountKeys) { try { - // Step 1: Delete all KEKs from the provider first List kekVersions = kmsKekVersionDao.listByKmsKeyId(key.getId()); if (kekVersions != null && !kekVersions.isEmpty()) { logger.debug("Deleting {} KEK version(s) from provider for KMS key {}", @@ -1157,15 +898,11 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } catch (Exception e) { logger.warn("Failed to delete KEK {} from provider: {}", kekVersion.getKekLabel(), e.getMessage()); - // Continue - still delete from database even if provider deletion fails } } } - // Step 2: Delete the KMS key from database - // This will CASCADE delete: - // - KEK versions (kms_kek_versions) - // - Wrapped keys (kms_wrapped_key) + // CASCADE deletes KEK versions and wrapped keys boolean deleted = kmsKeyDao.remove(key.getId()); if (deleted) { logger.debug("Deleted KMS key {} as part of account {} cleanup", key.getUuid(), accountId); @@ -1194,6 +931,596 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } } + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_CREATE, eventDescription = "Adding HSM profile") + public HSMProfile addHSMProfile(AddHSMProfileCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + + String protocol = cmd.getProtocol(); + if (StringUtils.isEmpty(protocol)) { + throw new InvalidParameterValueException("Protocol cannot be empty"); + } + + try { + getKMSProvider(protocol); + } catch (CloudRuntimeException e) { + throw new InvalidParameterValueException("No provider found for protocol: " + protocol); + } + + boolean isSystem = cmd.isSystem(); + if (isSystem && !accountManager.isRootAdmin(caller.getId())) { + throw new PermissionDeniedException("Only root admins can create system HSM profiles"); + } + + Account targetAccount = accountManager.finalizeOwner(caller, cmd.getAccountName(), cmd.getDomainId(), + cmd.getProjectId()); + + Long accountId = targetAccount.getId(); + Long domainId = targetAccount.getDomainId(); + + HSMProfileVO profile = new HSMProfileVO( + cmd.getName(), + protocol, + accountId, + domainId, + cmd.getZoneId(), + cmd.getVendorName()); + profile.setSystem(isSystem); + profile = hsmProfileDao.persist(profile); + + if (cmd.getDetails() != null) { + for (Map.Entry entry : cmd.getDetails().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + if (isSensitiveKey(key)) { + value = DBEncryptionUtil.encrypt(value); + } + + hsmProfileDetailsDao.persist(profile.getId(), key, value); + } + } + + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), profile.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_HSM_PROFILE_CREATE, + String.format("created HSM profile with id: %s, name: %s", profile.getUuid(), profile.getName()), + profile.getId(), ApiCommandResourceType.HsmProfile.toString(), CallContext.current().getStartEventId()); + + return profile; + } + + @Override + public ListResponse listHSMProfiles(ListHSMProfilesCmd cmd) { + Account caller = CallContext.current().getCallingAccount(); + if (caller == null) { + return new ListResponse<>(); + } + + List permittedAccounts = new ArrayList<>(); + Ternary domainIdRecursiveListProject = new Ternary<>( + cmd.getDomainId(), cmd.isRecursive(), null); + accountManager.buildACLSearchParameters(caller, cmd.getId(), cmd.getAccountName(), + cmd.getProjectId(), permittedAccounts, domainIdRecursiveListProject, + cmd.listAll(), false); + Long domainId = domainIdRecursiveListProject.first(); + Boolean isRecursive = domainIdRecursiveListProject.second(); + ListProjectResourcesCriteria listProjectResourcesCriteria = domainIdRecursiveListProject.third(); + + SearchBuilder sb = getSearchBuilderForHSMProfiles(domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + SearchCriteria sc = getSearchCriteriaForHSMProfiles(sb, cmd, caller, domainId, isRecursive, + permittedAccounts, listProjectResourcesCriteria); + + Filter searchFilter = new Filter(HSMProfileVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); + Pair, Integer> result = hsmProfileDao.searchAndCount(sc, searchFilter); + List profiles = result.first(); + Integer totalCount = result.second(); + + List responses = new ArrayList<>(); + + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + for (HSMProfileVO profile : profiles) { + // When isSystem=true, non-admin users explicitly requested system profiles, so + // don't mark as limited + // When listall=true, also don't mark as limited since user requested all + // profiles + // If the profile is owned by the user, they should see full details even if it + // is a system profile + boolean limited = profile.isSystem() && !isRootAdmin && !(cmd.getIsSystem() || cmd.listAll()) + && profile.getAccountId() != caller.getId(); + responses.add(createHSMProfileResponse(profile, limited)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, totalCount); + return listResponse; + } + + SearchBuilder getSearchBuilderForHSMProfiles(Long domainId, Boolean isRecursive, + List permittedAccounts, ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchBuilder sb = hsmProfileDao.createSearchBuilder(); + accountManager.buildACLSearchBuilder(sb, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("zoneId", sb.entity().getZoneId(), SearchCriteria.Op.EQ); + sb.and("protocol", sb.entity().getProtocol(), SearchCriteria.Op.EQ); + sb.and("enabled", sb.entity().isEnabled(), SearchCriteria.Op.EQ); + sb.and("system", sb.entity().isSystem(), SearchCriteria.Op.EQ); + sb.done(); + return sb; + } + + SearchCriteria getSearchCriteriaForHSMProfiles(SearchBuilder searchBuilder, + ListHSMProfilesCmd cmd, Account caller, Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchCriteria sc = searchBuilder.create(); + + sc.setParametersIfNotNull("id", cmd.getId()); + sc.setParametersIfNotNull("zoneId", cmd.getZoneId()); + sc.setParametersIfNotNull("protocol", cmd.getProtocol()); + sc.setParametersIfNotNull("enabled", cmd.getEnabled()); + sc.setParametersIfNotNull("system", cmd.getIsSystem()); + + // Access control for non-root-admins: + // system profiles (null account_id/domain_id) are globally visible to all + // users, + // so they must always be reachable via "system=true OR ". + // ANDing ACL criteria directly onto sc would exclude them because their + // account_id is NULL. + // + // The `system` field filter already set above (line sc.setParametersIfNotNull) + // correctly + // narrows the final result when the caller passes isSystem=true/false: + // isSystem=true → sc already has system=true → effective: WHERE system=true + // isSystem=false → sc already has system=false → effective: WHERE system=false + // AND ACL + // isSystem=null → no extra filter → effective: WHERE (system=true OR ACL) + // + // Root admins bypass ACL entirely and see everything filtered only by explicit + // params. + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + + if (!isRootAdmin) { + SearchCriteria systemOrAclSC = hsmProfileDao.createSearchCriteria(); + if (cmd.listAll()) { + systemOrAclSC.addOr("system", SearchCriteria.Op.EQ, true); + } + + SearchCriteria aclSC = searchBuilder.create(); + accountManager.buildACLSearchCriteria(aclSC, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + + if (StringUtils.isNotBlank(aclSC.getWhereClause()) && StringUtils.isNotBlank( + systemOrAclSC.getWhereClause())) { + systemOrAclSC.addOr("id", SearchCriteria.Op.SC, aclSC); + } else if (StringUtils.isNotBlank(aclSC.getWhereClause()) && StringUtils.isBlank( + systemOrAclSC.getWhereClause())) { + systemOrAclSC = aclSC; + } + + if (StringUtils.isNotBlank(systemOrAclSC.getWhereClause())) { + sc.addAnd("id", SearchCriteria.Op.SC, systemOrAclSC); + } + } + return sc; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_DELETE, eventDescription = "Deleting HSM profile") + public boolean deleteHSMProfile(DeleteHSMProfileCmd cmd) throws KMSException { + HSMProfileVO profile = getHSMProfile(cmd.getId()); + Account caller = CallContext.current().getCallingAccount(); + checkHSMProfileAccess(caller, profile, true); + + long keyCount = kmsKeyDao.countByHsmProfileId(profile.getId()); + if (keyCount > 0) { + throw new InvalidParameterValueException( + String.format("Cannot delete HSM profile '%s': it is referenced by %d KMS key(s). " + + "Please delete or reassign those keys first.", profile.getName(), keyCount)); + } + + // Check if any KEK versions reference this HSM profile + List kekVersions = kmsKekVersionDao.listByHsmProfileId(profile.getId()); + if (!kekVersions.isEmpty()) { + // Check if any wrapped keys are using these KEK versions + long wrappedKeyCount = 0; + for (KMSKekVersionVO kekVersion : kekVersions) { + wrappedKeyCount += kmsWrappedKeyDao.countByKekVersionId(kekVersion.getId()); + } + if (wrappedKeyCount > 0) { + throw new InvalidParameterValueException( + String.format("Cannot delete HSM profile '%s': it is referenced by %d wrapped key(s) " + + "through KEK versions. Please wait for key rotation to complete or delete those" + + " volumes first.", + profile.getName(), wrappedKeyCount)); + } + } + + getKMSProvider(profile.getProtocol()).invalidateProfileCache(profile.getId()); + hsmProfileDetailsDao.deleteDetails(profile.getId()); + if (hsmProfileDao.remove(profile.getId())) { + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), profile.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_HSM_PROFILE_DELETE, + String.format("Deleted HSM profile with id: %s, name: %s", profile.getUuid(), profile.getName()), + profile.getId(), ApiCommandResourceType.HsmProfile.toString(), + CallContext.current().getStartEventId()); + return true; + } + return false; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_UPDATE, eventDescription = "Updating HSM profile") + public HSMProfile updateHSMProfile(UpdateHSMProfileCmd cmd) throws KMSException { + HSMProfileVO profile = getHSMProfile(cmd.getId()); + Account caller = CallContext.current().getCallingAccount(); + checkHSMProfileAccess(caller, profile, true); + + if (cmd.getName() != null) { + profile.setName(cmd.getName()); + } + if (cmd.getEnabled() != null) { + profile.setEnabled(cmd.getEnabled()); + } + + hsmProfileDao.update(profile.getId(), profile); + + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), profile.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_HSM_PROFILE_UPDATE, + String.format("Updated HSM profile with id: %s, name: %s", profile.getUuid(), profile.getName()), + profile.getId(), ApiCommandResourceType.HsmProfile.toString(), CallContext.current().getStartEventId()); + return profile; + } + + @Override + public HSMProfileResponse createHSMProfileResponse(HSMProfile profile) { + return createHSMProfileResponse(profile, false); + } + + private HSMProfileResponse createHSMProfileResponse(HSMProfile profile, boolean limited) { + HSMProfileResponse response = new HSMProfileResponse(); + response.setId(profile.getUuid()); + response.setName(profile.getName()); + response.setVendorName(profile.getVendorName()); + response.setSystem(profile.isSystem()); + + if (profile.getZoneId() != null) { + DataCenterVO zone = dataCenterDao.findById(profile.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + } + + if (limited) { + return response; + } + + response.setProtocol(profile.getProtocol()); + response.setEnabled(profile.isEnabled()); + response.setCreated(profile.getCreated()); + + ApiResponseHelper.populateOwner(response, profile); + + List details = hsmProfileDetailsDao.listByProfileId(profile.getId()); + Map detailsMap = new HashMap<>(); + for (HSMProfileDetailsVO detail : details) { + detailsMap.put(detail.getName(), detail.getValue()); + } + response.setDetails(detailsMap); + response.setObjectName("hsmprofile"); + return response; + } + + boolean isSensitiveKey(String key) { + return key.equalsIgnoreCase("pin") || + key.equalsIgnoreCase("password") || + key.toLowerCase().contains("secret") || + key.equalsIgnoreCase("private_key"); + } + + /** + * Find a KMS key by ID and verify the caller has write access to it. + */ + private KMSKeyVO findKMSKeyAndCheckAccess(Long keyId, Account caller) { + KMSKeyVO key = kmsKeyDao.findById(keyId); + if (key == null) { + throw new InvalidParameterValueException("KMS key not found: " + keyId); + } + accountManager.checkAccess(caller, null, true, key); + return key; + } + + /** + * Find an HSM profile by ID, throwing InvalidParameterValueException if not + * found. + */ + private HSMProfileVO getHSMProfile(Long profileId) { + HSMProfileVO profile = hsmProfileDao.findById(profileId); + if (profile == null) { + throw new InvalidParameterValueException("HSM Profile not found: " + profileId); + } + return profile; + } + + /** + * Validate caller's access to an HSM profile. + * For system profiles: read/use access is open to all; modify access requires + * root admin. + * For owned profiles: delegates to ACL checkAccess. + */ + private void checkHSMProfileAccess(Account caller, HSMProfileVO profile, boolean requireModifyAccess) { + if (profile.isSystem()) { + if (requireModifyAccess && !accountManager.isRootAdmin(caller.getId())) { + throw new PermissionDeniedException("Only root admins can modify system HSM profiles"); + } + } else { + accountManager.checkAccess(caller, null, requireModifyAccess, profile); + } + } + + /** + * Parse and validate a key purpose string. Returns null if the input is null. + */ + private KeyPurpose parseKeyPurpose(String purpose) { + if (purpose == null) { + return null; + } + try { + return KeyPurpose.fromString(purpose); + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException( + "Invalid purpose: " + purpose + ". Valid values: volume, tls"); + } + } + + T retryOperation(KmsOperation operation) throws Exception { + int maxRetries = getRetryCount(); + int retryDelay = getRetryDelayMs(); + int timeoutSec = getOperationTimeoutSec(); + + Exception lastException = null; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + Future future = kmsOperationExecutor.submit(operation::execute); + try { + return future.get(timeoutSec, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + // Note: if the underlying provider makes a native (JNI/JNA) call, the daemon + // thread may remain blocked until the native call returns even after cancel — + // this is a known JVM limitation. The caller is unblocked regardless. + lastException = KMSException.transientError( + "KMS operation timed out after " + timeoutSec + "s", e); + logger.warn("KMS operation timed out (attempt {}/{}), timeout={}s", + attempt + 1, maxRetries + 1, timeoutSec); + } catch (ExecutionException e) { + future.cancel(true); + Throwable cause = e.getCause(); + lastException = (cause instanceof Exception) ? (Exception) cause : e; + + if (lastException instanceof KMSException && !((KMSException) lastException).isRetryable()) { + throw lastException; + } + + logger.warn("KMS operation failed (attempt {}/{}): {}", + attempt + 1, maxRetries + 1, lastException.getMessage()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted while waiting for KMS operation", e); + } + + if (attempt < maxRetries) { + try { + Thread.sleep((long) retryDelay * (attempt + 1)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted during KMS retry delay", ie); + } + } else { + logger.error("KMS operation failed after {} attempt(s)", maxRetries + 1); + } + } + + if (lastException != null) { + throw lastException; + } + + throw new CloudRuntimeException("KMS operation failed with no exception details"); + } + + protected int getOperationTimeoutSec() { + return KMSOperationTimeoutSec.value(); + } + + protected int getRetryCount() { + return KMSRetryCount.value(); + } + + protected int getRetryDelayMs() { + return KMSRetryDelayMs.value(); + } + + private KMSException handleKmsException(Exception e) { + if (e instanceof KMSException) { + return (KMSException) e; + } + return KMSException.transientError("KMS operation failed: " + e.getMessage(), e); + } + + public void setKmsProviders(List kmsProviders) { + this.kmsProviders = kmsProviders; + initializeKmsProviderMap(); + } + + private void initializeKmsProviderMap() { + if (kmsProviders == null) { + return; + } + kmsProviderMap.clear(); + for (KMSProvider provider : kmsProviders) { + if (provider != null) { + kmsProviderMap.put(provider.getProviderName().toLowerCase(), provider); + logger.info("Registered KMS provider: {}", provider.getProviderName()); + } + } + } + + @Override + public boolean start() { + super.start(); + initializeKmsProviderMap(); + + for (KMSProvider provider : kmsProviderMap.values()) { + if (provider != null) { + try { + boolean healthy = provider.healthCheck(); + if (healthy) { + logger.info("KMS provider {} health check passed", provider.getProviderName()); + } else { + logger.warn("KMS provider {} health check failed", provider.getProviderName()); + } + } catch (Exception e) { + logger.warn("KMS provider {} health check error: {}", provider.getProviderName(), e.getMessage()); + } + } + } + + scheduleRewrapWorker(); + + return true; + } + + private void scheduleRewrapWorker() { + final ManagedContextTimerTask rewrapTask = new ManagedContextTimerTask() { + @Override + protected void runInContext() { + try { + processRewrapBatch(); + } catch (final Exception e) { + logger.error("Error while running KMS rewrap worker", e); + } + } + }; + + long intervalMs = KMSRewrapIntervalMs.value(); + rewrapTimer = new Timer("KMSRewrapWorker", true); // daemon so it doesn't block JVM shutdown + rewrapTimer.schedule(rewrapTask, 10000L, intervalMs); + logger.info("KMS rewrap worker scheduled with interval: {} ms", intervalMs); + } + + /** + * Finds KEK versions marked as Previous and gradually rewraps wrapped keys + * using the active version. + */ + private void processRewrapBatch() { + try { + List previousVersions = kmsKekVersionDao.findByStatus(KMSKekVersionVO.Status.Previous); + + if (previousVersions.isEmpty()) { + logger.trace("No KEK versions pending rewrap"); + return; + } + + logger.debug("Found {} KEK version(s) with status Previous - processing rewrap batches", + previousVersions.size()); + + int batchSize = KMSRewrapBatchSize.value(); + + for (KMSKekVersionVO oldVersion : previousVersions) { + try { + processVersionRewrap(oldVersion, batchSize); + } catch (Exception e) { + logger.error("Error processing rewrap for KEK version {}: {}", oldVersion, e.getMessage(), e); + } + } + } catch (Exception e) { + logger.error("Error in rewrap worker: {}", e.getMessage(), e); + } + } + + private void processVersionRewrap(KMSKekVersionVO oldVersion, int batchSize) throws KMSException { + KMSKeyVO kmsKey = kmsKeyDao.findById(oldVersion.getKmsKeyId()); + if (kmsKey == null) { + logger.warn("KMS key not found for KEK version {}, skipping", oldVersion); + return; + } + + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(oldVersion.getKmsKeyId()); + if (activeVersion == null) { + logger.warn("No active KEK version found for KMS key {}, skipping", kmsKey); + return; + } + + List keysToRewrap = kmsWrappedKeyDao.listByKekVersionId(oldVersion.getId(), batchSize); + + if (keysToRewrap.isEmpty()) { + logger.info("All wrapped keys rewrapped for KEK version {} (v{}) - archiving", + oldVersion.getUuid(), oldVersion.getVersionNumber()); + + oldVersion.setStatus(KMSKekVersionVO.Status.Archived); + kmsKekVersionDao.update(oldVersion.getId(), oldVersion); + + return; + } + + HSMProfileVO hsmProfile = hsmProfileDao.findById(activeVersion.getHsmProfileId()); + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + + int successCount = 0; + int failureCount = 0; + + for (KMSWrappedKeyVO wrappedKeyVO : keysToRewrap) { + try { + rewrapSingleKey(wrappedKeyVO, kmsKey, activeVersion, provider); + successCount++; + } catch (Exception e) { + failureCount++; + logger.warn("Failed to rewrap key {} for KMS key {}: {}", + wrappedKeyVO.getId(), kmsKey, e.getMessage()); + // Continue with next key - will retry in next run + } + } + + logger.info("Rewrapped batch for KMS key {} (KEK v{} -> v{}): {} success, {} failures", + kmsKey, oldVersion.getVersionNumber(), activeVersion.getVersionNumber(), + successCount, failureCount); + } + + void rewrapSingleKey(KMSWrappedKeyVO wrappedKeyVO, KMSKeyVO kmsKey, + KMSKekVersionVO newVersion, KMSProvider provider) { + byte[] dek = null; + try { + dek = unwrapKey(wrappedKeyVO.getId()); + + WrappedKey newWrapped = provider.wrapKey( + dek, + kmsKey.getPurpose(), + newVersion.getKekLabel(), + newVersion.getHsmProfileId()); + + wrappedKeyVO.setKekVersionId(newVersion.getId()); + wrappedKeyVO.setWrappedBlob(newWrapped.getWrappedKeyMaterial()); + kmsWrappedKeyDao.update(wrappedKeyVO.getId(), wrappedKeyVO); + } finally { + if (dek != null) { + Arrays.fill(dek, (byte) 0); + } + } + } + + @Override + public boolean stop() { + if (rewrapTimer != null) { + rewrapTimer.cancel(); + rewrapTimer = null; + } + kmsOperationExecutor.shutdownNow(); + return super.stop(); + } + @Override public String getConfigComponentName() { return KMSManager.class.getSimpleName(); @@ -1202,7 +1529,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ - KMSEnabled, KMSDekSizeBits, KMSRetryCount, KMSRetryDelayMs, @@ -1214,7 +1540,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public List> getCommands() { - List> cmdList = new ArrayList<>(); + List> cmdList = new ArrayList<>(); cmdList.add(ListKMSKeysCmd.class); cmdList.add(CreateKMSKeyCmd.class); cmdList.add(UpdateKMSKeyCmd.class); @@ -1229,204 +1555,8 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return cmdList; } - // ==================== HSM Profile Management ==================== - - @Override - public HSMProfile addHSMProfile(AddHSMProfileCmd cmd) throws KMSException { - // Validate inputs - String protocol = cmd.getProtocol(); - if (StringUtils.isEmpty(protocol)) { - throw KMSException.invalidParameter("Protocol cannot be empty"); - } - - // Ensure provider exists for protocol - try { - getKMSProvider(protocol); - } catch (CloudRuntimeException e) { - throw KMSException.invalidParameter("No provider found for protocol: " + protocol); - } - - HSMProfileVO profile = new HSMProfileVO( - cmd.getName(), - protocol, - cmd.getAccountId(), - cmd.getDomainId(), - cmd.getZoneId(), - cmd.getVendorName() - ); - - // Persist profile - profile = hsmProfileDao.persist(profile); - - // Persist details - if (cmd.getDetails() != null) { - for (Map.Entry entry : cmd.getDetails().entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - - // Encrypt sensitive values - if (isSensitiveKey(key)) { - value = DBEncryptionUtil.encrypt(value); - } - - hsmProfileDetailsDao.persist(profile.getId(), key, value); - } - } - - return profile; - } - - @Override - public List listHSMProfiles(ListHSMProfilesCmd cmd) { - Long accountId = CallContext.current().getCallingAccount().getId(); - boolean isAdmin = accountManager.isAdmin(accountId); - - List result = new ArrayList<>(); - - if (cmd.getId() != null) { - HSMProfileVO key = hsmProfileDao.findById(cmd.getId()); - if (key == null) { - return result; - } - // Validate the caller can list this profile - if (!isAdmin) { - Account caller = CallContext.current().getCallingAccount(); - Account owner = accountManager.getAccount(key.getAccountId()); - - accountManager.checkAccess(caller, null, true, owner); - } - result.add(key); - return result; - } - - // 1. User's own profiles - result.addAll(hsmProfileDao.listByAccountId(accountId)); - - // 2. Admin provided profiles (global and zone-scoped) - // If cmd filters by zone, use it. Else return all relevant ones. - if (cmd.getZoneId() != null) { - result.addAll(hsmProfileDao.listAdminProfiles(cmd.getZoneId())); - result.addAll(hsmProfileDao.listAdminProfiles()); // Global ones too - } else { - // No zone filter - get all admin profiles if user can see them - result.addAll(hsmProfileDao.listAdminProfiles()); - // How to list all zone-specific ones? listAdminProfiles() only gets globals? - // Need a way to get all. For now simplified. - } - - // Apply memory filtering for protocol and enabled status - return result.stream() - .filter(p -> cmd.getProtocol() == null || p.getProtocol().equalsIgnoreCase(cmd.getProtocol())) - .filter(p -> cmd.getEnabled() == null || p.isEnabled() == cmd.getEnabled()) - .collect(Collectors.toList()); - } - - @Override - public boolean deleteHSMProfile(DeleteHSMProfileCmd cmd) throws KMSException { - HSMProfileVO profile = hsmProfileDao.findById(cmd.getId()); - if (profile == null) { - throw KMSException.invalidParameter("HSM Profile not found"); - } - - // Check permissions (handled by BaseCmd entity owner usually, but double check) - Account caller = CallContext.current().getCallingAccount(); - // Permission check logic here... - - // Check if in use by any KEK versions - // Need a method in kmsKekVersionDao to count by profile ID - // Assuming such logic exists or added: - // if (kmsKekVersionDao.countByProfileId(profile.getId()) > 0) { ... } - - // Delete details - hsmProfileDetailsDao.deleteDetails(profile.getId()); - - // Delete profile - return hsmProfileDao.remove(profile.getId()); - } - - @Override - public HSMProfile updateHSMProfile(UpdateHSMProfileCmd cmd) throws KMSException { - HSMProfileVO profile = hsmProfileDao.findById(cmd.getId()); - if (profile == null) { - throw KMSException.invalidParameter("HSM Profile not found"); - } - - if (cmd.getName() != null) { - profile.setName(cmd.getName()); - } - if (cmd.getEnabled() != null) { - profile.setEnabled(cmd.getEnabled()); - } - - hsmProfileDao.update(profile.getId(), profile); - - if (cmd.getDetails() != null) { - for (Map.Entry entry : cmd.getDetails().entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - - // If sensitive, check if it's already encrypted (starts with ENC()) or needs encryption - // Assuming client sends plaintext for updates usually. - // Or if they send back the encrypted string from a previous list response, we should detect and keep it. - // Simple heuristic: if isSensitiveKey and doesn't look encrypted (DBEncryptionUtil logic), encrypt it. - // For now, simpler: always encrypt new sensitive values. - - if (isSensitiveKey(key)) { - value = DBEncryptionUtil.encrypt(value); - } - - HSMProfileDetailsVO detail = hsmProfileDetailsDao.findDetail(profile.getId(), key); - if (detail != null) { - detail.setValue(value); - hsmProfileDetailsDao.update(detail.getId(), detail); - } else { - hsmProfileDetailsDao.persist(profile.getId(), key, value); - } - } - } - - return profile; - } - - @Override - public HSMProfileResponse createHSMProfileResponse(HSMProfile profile) { - HSMProfileResponse response = new HSMProfileResponse(); - response.setId(profile.getUuid()); - response.setName(profile.getName()); - response.setProtocol(profile.getProtocol()); - response.setVendorName(profile.getVendorName()); - response.setEnabled(profile.isEnabled()); - response.setCreated(profile.getCreated()); - - if (profile.getAccountId() != null) { - Account account = accountManager.getAccount(profile.getAccountId()); - if (account != null) { - response.setAccountId(account.getUuid()); - response.setAccountName(account.getAccountName()); - } - } - - // Populate details - List details = hsmProfileDetailsDao.listByProfileId(profile.getId()); - Map detailsMap = new HashMap<>(); - for (HSMProfileDetailsVO detail : details) { - detailsMap.put(detail.getName(), detail.getValue()); // Return encrypted values as-is - } - response.setDetails(detailsMap); - - return response; - } - - boolean isSensitiveKey(String key) { - // List of keys known to be sensitive - return key.equalsIgnoreCase("pin") || - key.equalsIgnoreCase("password") || - key.toLowerCase().contains("secret") || - key.equalsIgnoreCase("private_key"); - } - @FunctionalInterface - private interface KmsOperation { + interface KmsOperation { T execute() throws Exception; } } diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java index 4cf94aa77ea..8aad751d4b9 100644 --- a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java @@ -17,15 +17,9 @@ package org.apache.cloudstack.kms; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Arrays; - +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.domain.dao.DomainDao; +import com.cloud.user.AccountManager; import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.kms.dao.HSMProfileDao; import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao; @@ -36,7 +30,14 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; -import com.cloud.user.AccountManager; +import java.util.Arrays; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Unit tests for HSM-related business logic in KMSManagerImpl @@ -45,20 +46,20 @@ import com.cloud.user.AccountManager; @RunWith(MockitoJUnitRunner.class) public class KMSManagerImplHSMTest { + private final Long testAccountId = 100L; @Spy @InjectMocks private KMSManagerImpl kmsManager; - @Mock private HSMProfileDao hsmProfileDao; - @Mock private HSMProfileDetailsDao hsmProfileDetailsDao; - @Mock private AccountManager accountManager; - - private Long testAccountId = 100L; + @Mock + private DataCenterDao dataCenterDao; + @Mock + private DomainDao domainDao; /** * Test: isSensitiveKey correctly identifies "pin" as sensitive diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java index 6ce1c0049c9..147181ce3d2 100644 --- a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java @@ -17,6 +17,23 @@ package org.apache.cloudstack.kms; +import com.cloud.event.ActionEventUtils; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; @@ -28,21 +45,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.apache.cloudstack.framework.kms.KMSException; -import org.apache.cloudstack.framework.kms.KMSProvider; -import org.apache.cloudstack.framework.kms.KeyPurpose; -import org.apache.cloudstack.kms.dao.HSMProfileDao; -import org.apache.cloudstack.kms.dao.KMSKekVersionDao; -import org.apache.cloudstack.kms.dao.KMSKeyDao; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.MockitoJUnitRunner; - /** * Unit tests for KMS key creation logic in KMSManagerImpl * Tests key creation with explicit and auto-resolved HSM profiles @@ -50,33 +52,22 @@ import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class KMSManagerImplKeyCreationTest { + private final Long testAccountId = 100L; + private final Long testDomainId = 1L; + private final Long testZoneId = 1L; + private final String testProviderName = "pkcs11"; @Spy @InjectMocks private KMSManagerImpl kmsManager; - @Mock private KMSKeyDao kmsKeyDao; - @Mock private KMSKekVersionDao kmsKekVersionDao; - @Mock private HSMProfileDao hsmProfileDao; - @Mock private KMSProvider kmsProvider; - private Long testAccountId = 100L; - private Long testDomainId = 1L; - private Long testZoneId = 1L; - private String testProviderName = "pkcs11"; - - @Before - public void setUp() { - // Setup provider - when(kmsProvider.getProviderName()).thenReturn(testProviderName); - } - /** * Test: createUserKMSKey uses explicit HSM profile when provided */ @@ -87,13 +78,12 @@ public class KMSManagerImplKeyCreationTest { Long hsmProfileId = 10L; HSMProfileVO profile = mock(HSMProfileVO.class); - when(profile.getAccountId()).thenReturn(testAccountId); when(profile.getProtocol()).thenReturn(testProviderName); when(hsmProfileDao.findById(hsmProfileId)).thenReturn(profile); // Mock provider KEK creation when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId))) - .thenReturn("test-kek-label"); + .thenReturn("test-kek-label"); // Mock DAO persist operations KMSKeyVO mockKey = mock(KMSKeyVO.class); @@ -103,23 +93,27 @@ public class KMSManagerImplKeyCreationTest { KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); - // Mock getKMSProvider to return our mock provider - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, - testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + actionEventUtils.when(() -> ActionEventUtils.onCompletedActionEvent( + Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.anyInt())).thenReturn(2L); + KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, + testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); - // Verify explicit profile was used - assertNotNull(result); - verify(hsmProfileDao).findById(hsmProfileId); - verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(hsmProfileId)); + // Verify explicit profile was used + assertNotNull(result); + verify(hsmProfileDao).findById(hsmProfileId); + verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(hsmProfileId)); - // Verify KMSKeyVO was created with correct profile ID - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); - verify(kmsKeyDao).persist(keyCaptor.capture()); - KMSKeyVO createdKey = keyCaptor.getValue(); - assertEquals(hsmProfileId, createdKey.getHsmProfileId()); + // Verify KMSKeyVO was created with correct profile ID + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); + verify(kmsKeyDao).persist(keyCaptor.capture()); + KMSKeyVO createdKey = keyCaptor.getValue(); + assertEquals(hsmProfileId, createdKey.getHsmProfileId()); + } } /** @@ -132,10 +126,8 @@ public class KMSManagerImplKeyCreationTest { long hsmProfileId = 1L; when(hsmProfileDao.findById(hsmProfileId)).thenReturn(null); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); - kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, - "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); } /** @@ -148,11 +140,10 @@ public class KMSManagerImplKeyCreationTest { HSMProfileVO profile = mock(HSMProfileVO.class); when(profile.getProtocol()).thenReturn(testProviderName); - when(profile.getAccountId()).thenReturn(testAccountId); when(hsmProfileDao.findById(hsmProfileId)).thenReturn(profile); when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId))) - .thenReturn("test-kek-label"); + .thenReturn("test-kek-label"); KMSKeyVO mockKey = mock(KMSKeyVO.class); when(mockKey.getId()).thenReturn(1L); @@ -161,18 +152,24 @@ public class KMSManagerImplKeyCreationTest { KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, - "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + actionEventUtils.when(() -> ActionEventUtils.onCompletedActionEvent( + Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.anyInt())).thenReturn(2L); - // Verify KEK version was created with correct profile ID - ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); - verify(kmsKekVersionDao).persist(versionCaptor.capture()); - KMSKekVersionVO createdVersion = versionCaptor.getValue(); - assertEquals(hsmProfileId, createdVersion.getHsmProfileId()); - assertEquals(Integer.valueOf(1), Integer.valueOf(createdVersion.getVersionNumber())); - assertEquals("test-kek-label", createdVersion.getKekLabel()); + kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + + // Verify KEK version was created with correct profile ID + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); + verify(kmsKekVersionDao).persist(versionCaptor.capture()); + KMSKekVersionVO createdVersion = versionCaptor.getValue(); + assertEquals(hsmProfileId, createdVersion.getHsmProfileId()); + assertEquals(Integer.valueOf(1), createdVersion.getVersionNumber()); + assertEquals("test-kek-label", createdVersion.getKekLabel()); + } } } diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java index a256f6dd46a..2631fd75817 100644 --- a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java @@ -34,7 +34,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; -import java.util.Arrays; +import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -74,8 +74,8 @@ public class KMSManagerImplKeyRotationTest { @Mock private KMSProvider kmsProvider; - private Long testZoneId = 1L; - private String testProviderName = "pkcs11"; + private final Long testZoneId = 1L; + private final String testProviderName = "pkcs11"; @Before public void setUp() { @@ -94,7 +94,6 @@ public class KMSManagerImplKeyRotationTest { KMSKeyVO kmsKey = mock(KMSKeyVO.class); when(kmsKey.getId()).thenReturn(kmsKeyId); - when(kmsKey.getZoneId()).thenReturn(testZoneId); when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); @@ -108,7 +107,7 @@ public class KMSManagerImplKeyRotationTest { when(oldVersion.getVersionNumber()).thenReturn(1); when(oldVersion.getId()).thenReturn(10L); when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); - when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion)); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); // Provider creates new KEK when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(oldProfileId))) @@ -119,7 +118,6 @@ public class KMSManagerImplKeyRotationTest { when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); String result = kmsManager.rotateKek(kmsKey, oldKekLabel, newKekLabel, 256, null); @@ -135,7 +133,7 @@ public class KMSManagerImplKeyRotationTest { ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); verify(kmsKekVersionDao).persist(versionCaptor.capture()); KMSKekVersionVO createdVersion = versionCaptor.getValue(); - assertEquals(Integer.valueOf(2), Integer.valueOf(createdVersion.getVersionNumber())); + assertEquals(Integer.valueOf(2), createdVersion.getVersionNumber()); assertEquals(oldProfileId, createdVersion.getHsmProfileId()); } @@ -153,7 +151,6 @@ public class KMSManagerImplKeyRotationTest { KMSKeyVO kmsKey = mock(KMSKeyVO.class); when(kmsKey.getId()).thenReturn(kmsKeyId); - when(kmsKey.getZoneId()).thenReturn(testZoneId); when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); @@ -165,7 +162,7 @@ public class KMSManagerImplKeyRotationTest { when(oldVersion.getVersionNumber()).thenReturn(1); when(oldVersion.getId()).thenReturn(10L); when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); - when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion)); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); // Provider creates new KEK in different HSM when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(newProfileId))) @@ -176,7 +173,6 @@ public class KMSManagerImplKeyRotationTest { when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); String result = kmsManager.rotateKek(kmsKey, oldKekLabel, newKekLabel, 256, hsmProfile); @@ -257,7 +253,6 @@ public class KMSManagerImplKeyRotationTest { when(kmsKey.getId()).thenReturn(kmsKeyId); when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); - when(kmsKey.getZoneId()).thenReturn(testZoneId); HSMProfileVO hsmProfile = mock(HSMProfileVO.class); when(hsmProfile.getId()).thenReturn(oldProfileId); @@ -268,7 +263,7 @@ public class KMSManagerImplKeyRotationTest { when(oldVersion.getVersionNumber()).thenReturn(1); when(oldVersion.getId()).thenReturn(10L); when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); - when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion)); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); // Provider creates new KEK - will accept any label when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(oldProfileId))) @@ -279,7 +274,6 @@ public class KMSManagerImplKeyRotationTest { when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); kmsManager.rotateKek(kmsKey, oldKekLabel, null, 256, null); @@ -291,15 +285,14 @@ public class KMSManagerImplKeyRotationTest { } /** - * Test: rotateKek throws exception when old KEK not found + * Test: rotateKek throws exception when old KEK not found (provider rejects the rotation) */ @Test(expected = KMSException.class) public void testRotateKek_ThrowsExceptionWhenOldKekNotFound() throws KMSException { - // Setup: Old KEK doesn't exist Long oldProfileId = 10L; + Long kmsKeyId = 1L; KMSKeyVO kmsKey = mock(KMSKeyVO.class); - when(kmsKey.getZoneId()).thenReturn(testZoneId); when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); @@ -308,11 +301,13 @@ public class KMSManagerImplKeyRotationTest { when(hsmProfile.getProtocol()).thenReturn(testProviderName); when(hsmProfileDao.findById(oldProfileId)).thenReturn(hsmProfile); - doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); + // Provider throws because the old KEK label doesn't exist in the HSM + when(kmsProvider.createKek(any(KeyPurpose.class), eq("new-label"), eq(256), eq(oldProfileId))) + .thenThrow(KMSException.kekNotFound("Old KEK not found: non-existent-label")); - kmsManager.rotateKek(kmsKey, - "non-existent-label", "new-label", 256, null); + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + kmsManager.rotateKek(kmsKey, "non-existent-label", "new-label", 256, null); } /** @@ -327,7 +322,6 @@ public class KMSManagerImplKeyRotationTest { KMSKeyVO kmsKey = mock(KMSKeyVO.class); when(kmsKey.getId()).thenReturn(kmsKeyId); - when(kmsKey.getZoneId()).thenReturn(testZoneId); when(kmsKey.getHsmProfileId()).thenReturn(currentProfileId); when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); @@ -340,7 +334,7 @@ public class KMSManagerImplKeyRotationTest { when(oldVersion.getVersionNumber()).thenReturn(1); when(oldVersion.getId()).thenReturn(10L); when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); - when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion)); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(currentProfileId))) .thenReturn("new-kek-id"); @@ -350,7 +344,6 @@ public class KMSManagerImplKeyRotationTest { when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); kmsManager.rotateKek(kmsKey, oldKekLabel, "new-label", 256, null); diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java new file mode 100644 index 00000000000..11e694a6441 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java @@ -0,0 +1,159 @@ +// 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 org.apache.cloudstack.kms; + +import org.apache.cloudstack.framework.kms.KMSException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.doReturn; + +/** + * Unit tests for KMSManagerImpl's retryOperation() logic, covering + * timeout enforcement, retry-on-transient-failure, and non-retryable fast-fail. + * + * Config values (retry count, delay, timeout) are spied on so tests remain + * fast without needing a full management-server config context. + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplRetryTest { + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + /** Configure the spy to use a 1-second timeout, the given retry count, and no delay. */ + private void useShortConfig(int retries) { + doReturn(1).when(kmsManager).getOperationTimeoutSec(); + doReturn(retries).when(kmsManager).getRetryCount(); + doReturn(0).when(kmsManager).getRetryDelayMs(); + } + + /** + * Normal path: operation completes immediately, result returned. + */ + @Test + public void testRetryOperation_succeedsImmediately() throws Exception { + useShortConfig(0); + + String result = kmsManager.retryOperation(() -> "ok"); + + assertEquals("ok", result); + } + + /** + * Timeout path: operation never finishes within the configured timeout. + * retryOperation() must unblock and throw a retryable KMSException. + */ + @Test + public void testRetryOperation_timesOutAndThrowsKMSException() { + useShortConfig(0); + + try { + kmsManager.retryOperation(() -> { + Thread.sleep(5_000); + return "should never reach here"; + }); + fail("Expected KMSException due to timeout"); + } catch (KMSException e) { + assertTrue("Exception should be retryable (transient timeout)", e.isRetryable()); + assertTrue("Message should mention timeout", e.getMessage().contains("timed out")); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + /** + * Retry path: operation fails with a retryable KMSException on the first + * attempt and succeeds on the second. retryOperation() should return the + * successful result. + */ + @Test + public void testRetryOperation_retriesOnTransientFailureAndSucceeds() throws Exception { + useShortConfig(2); + AtomicInteger attempts = new AtomicInteger(0); + + String result = kmsManager.retryOperation(() -> { + if (attempts.getAndIncrement() == 0) { + throw KMSException.transientError("temporary HSM error", null); + } + return "recovered"; + }); + + assertEquals("recovered", result); + assertEquals("Should have taken exactly 2 attempts", 2, attempts.get()); + } + + /** + * Non-retryable path: a KMSException with isRetryable() == false must be + * re-thrown immediately without consuming any retry budget. + */ + @Test + public void testRetryOperation_nonRetryableExceptionFastFails() { + useShortConfig(3); + AtomicInteger attempts = new AtomicInteger(0); + + try { + kmsManager.retryOperation(() -> { + attempts.incrementAndGet(); + throw KMSException.invalidParameter("bad key label"); + }); + fail("Expected non-retryable KMSException"); + } catch (KMSException e) { + assertFalse("Exception should NOT be retryable", e.isRetryable()); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName()); + } + + assertEquals("Non-retryable exception must not trigger retries", 1, attempts.get()); + } + + /** + * Retry exhaustion on timeout: all attempts time out; retryOperation() + * must eventually throw after exhausting the retry budget. + */ + @Test + public void testRetryOperation_exhaustsRetriesOnRepeatedTimeout() { + useShortConfig(2); // 3 total attempts (initial + 2 retries), each timing out after 1s + AtomicInteger attempts = new AtomicInteger(0); + + try { + kmsManager.retryOperation(() -> { + attempts.incrementAndGet(); + Thread.sleep(5_000); + return "never"; + }); + fail("Expected KMSException after retry exhaustion"); + } catch (KMSException e) { + assertTrue("Final exception should be retryable (timeout)", e.isRetryable()); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName()); + } + + assertEquals("Should have attempted exactly 3 times (1 initial + 2 retries)", 3, attempts.get()); + } +} diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index ce2b458c104..185a80c23bd 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1434,7 +1434,20 @@ "label.keyboardtype": "Keyboard type", "label.keypair": "SSH key pair", "label.keypairs": "SSH key pair(s)", +"label.kms": "Key Management", "label.kms.key": "KMS Key", +"label.kmskey": "KMS Key", +"label.kms.keys": "KMS Keys", +"label.create.kms.key": "Create KMS Key", +"label.update.kms.key": "Update KMS Key", +"label.rotate.kms.key": "Rotate KMS Key", +"label.delete.kms.key": "Delete KMS Key", +"label.migrate.volumes.to.kms": "Migrate Volumes to KMS", +"label.hsm.profile": "HSM Profile", +"label.hsmprofile": "HSM Profile", +"label.create.hsmprofile": "Add HSM Profile", +"label.update.hsm.profile": "Update HSM Profile", +"label.delete.hsm.profile": "Delete HSM Profile", "label.select.kms.key.optional": "Select KMS Key (optional)", "label.kubeconfig.cluster": "Kubernetes Cluster config", "label.kubernetes": "Kubernetes", @@ -1633,6 +1646,7 @@ "label.migrate.instance.specific.storages": "Migrate volume(s) of the Instance to specific primary storages", "label.migrate.systemvm.to": "Migrate System VM to", "label.migrate.volume": "Migrate Volume", +"label.migrate.volume.to.kms": "Migrate Volume Encryption to KMS", "message.memory.usage.info.hypervisor.additionals": "The data shown may not reflect the actual memory usage if the Instance does not have the additional hypervisor tools installed", "message.memory.usage.info.negative.value": "If the Instance's memory usage cannot be obtained from the hypervisor, the lines for free memory in the raw data graph and memory usage in the percentage graph will be disabled", "message.migrate.volume.tooltip": "Volume can be migrated to any suitable storage pool. Admin has to choose the appropriate disk offering to replace, that supports the new storage pool", @@ -2969,9 +2983,11 @@ "message.action.delete.guest.os": "Please confirm that you want to delete this guest os. System defined entry cannot be deleted.", "message.action.delete.guest.os.category": "Please confirm that you want to delete this guest os category.", "message.action.delete.guest.os.hypervisor.mapping": "Please confirm that you want to delete this guest os hypervisor mapping. System defined entry cannot be deleted.", +"message.action.delete.hsm.profile": "Please confirm that you want to delete this HSM profile.", "message.action.delete.instance.group": "Please confirm that you want to delete the Instance group.", "message.action.delete.interface.static.route": "Please confirm that you want to remove this interface Static Route?", "message.action.delete.iso": "Please confirm that you want to delete this ISO.", +"message.action.delete.kms.key": "Please confirm that you want to delete this KMS key.", "message.action.delete.network": "Please confirm that you want to delete this Network.", "message.action.delete.network.static.route": "Please confirm that you want to remove this Network Static Route", "message.action.delete.nexusvswitch": "Please confirm that you want to delete this nexus 1000v", @@ -3709,6 +3725,8 @@ "message.migrate.volume.failed": "Migrating volume failed.", "message.migrate.volume.pool.auto.assign": "Primary storage for the volume will be automatically chosen based on the suitability and Instance destination", "message.migrate.volume.processing": "Migrating volume...", +"message.action.migrate.volume.to.kms": "Please confirm that you want to migrate this volume's passphrase encryption to KMS. This operation re-encrypts the volume key using the selected KMS key and cannot be undone.", +"message.action.migrate.volumes.to.kms": "Please confirm that you want to migrate volumes to KMS encryption. This operation re-encrypts volume keys using the selected KMS key and cannot be undone.", "message.migrate.with.storage": "Specify storage pool for volumes of the Instance.", "message.migrating.failed": "Migration failed.", "message.migrating.processing": "Migration in progress for", diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index 4145eeb9be6..93af6b15571 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -189,7 +189,7 @@
    {{ dataResource[item].rbd_default_data_pool }}
    - +
    {{ $t('label.configuration.details') }}
    diff --git a/ui/src/components/view/InfoCard.vue b/ui/src/components/view/InfoCard.vue index 996e30ead3b..09763fcc362 100644 --- a/ui/src/components/view/InfoCard.vue +++ b/ui/src/components/view/InfoCard.vue @@ -413,6 +413,30 @@
    +
    +
    {{ $t('label.kms.key') }}
    +
    + + + {{ resource.kmskey }} + + {{ resource.kmskey }} +
    +
    +
    +
    {{ $t('label.hsm.profile') }}
    +
    + + + {{ resource.hsmprofile }} + + {{ resource.hsmprofile }} +
    +
    {{ $t('label.network') }}
    diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index 66dd6b3db9e..50b80d43cc6 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -650,6 +650,10 @@ + @@ -1219,7 +1223,8 @@ export default { '/zone', '/pod', '/cluster', '/host', '/storagepool', '/imagestore', '/systemvm', '/router', '/ilbvm', '/annotation', '/computeoffering', '/systemoffering', '/diskoffering', '/backupoffering', '/networkoffering', '/vpcoffering', '/tungstenfabric', '/oauthsetting', '/guestos', '/guestoshypervisormapping', '/webhook', 'webhookdeliveries', 'webhookfilters', '/quotatariff', '/sharedfs', - '/ipv4subnets', '/managementserver', '/gpucard', '/gpudevices', '/vgpuprofile', '/extension', '/snapshotpolicy', '/backupschedule'].join('|')) + '/ipv4subnets', '/managementserver', '/gpucard', '/gpudevices', '/vgpuprofile', '/extension', '/snapshotpolicy', '/backupschedule', + '/kmskey', '/hsmprofile'].join('|')) .test(this.$route.path) }, enableGroupAction () { diff --git a/ui/src/components/view/SearchFilter.vue b/ui/src/components/view/SearchFilter.vue index 1b38ae6820d..fa4ab4ebd54 100644 --- a/ui/src/components/view/SearchFilter.vue +++ b/ui/src/components/view/SearchFilter.vue @@ -166,6 +166,18 @@ export default { responseKey1: 'listnetworksresponse', responseKey2: 'network', field: 'name' + }, + hsmprofileid: { + apiName: 'listHSMProfiles', + responseKey1: 'listhsmprofilesresponse', + responseKey2: 'hsmprofile', + field: 'name' + }, + kmskeyid: { + apiName: 'listKMSKeys', + responseKey1: 'listkmskeysresponse', + responseKey2: 'kmskey', + field: 'name' } } } @@ -217,6 +229,12 @@ export default { if (fieldName === 'groupid') { fieldName = 'group' } + if (fieldName === 'hsmprofileid') { + fieldName = 'hsm.profile' + } + if (fieldName === 'kmskeyid') { + fieldName = 'kms.key' + } if (fieldName === 'keyword') { if ('listAnnotations' in this.$store.getters.apis) { return this.$t('label.annotation') diff --git a/ui/src/components/view/SearchView.vue b/ui/src/components/view/SearchView.vue index bd952f04947..60809328929 100644 --- a/ui/src/components/view/SearchView.vue +++ b/ui/src/components/view/SearchView.vue @@ -275,6 +275,12 @@ export default { if (fieldName === 'groupid') { fieldName = 'group' } + if (fieldName === 'hsmprofileid') { + fieldName = 'hsm.profile' + } + if (fieldName === 'kmskeyid') { + fieldName = 'kms.key' + } if (fieldName === 'keyword') { if ('listAnnotations' in this.$store.getters.apis) { return this.$t('label.annotation') @@ -320,12 +326,18 @@ export default { if (item === 'backupofferingid' && !('listBackupOfferings' in this.$store.getters.apis)) { return true } + if (item === 'hsmprofileid' && !('listHSMProfiles' in this.$store.getters.apis)) { + return true + } + if (item === 'kmskeyid' && !('listKMSKeys' in this.$store.getters.apis)) { + return true + } if (['zoneid', 'domainid', 'imagestoreid', 'storageid', 'state', 'account', 'hypervisor', 'level', 'clusterid', 'podid', 'groupid', 'entitytype', 'accounttype', 'systemvmtype', 'scope', 'provider', 'type', 'scope', 'managementserverid', 'serviceofferingid', 'diskofferingid', 'networkid', 'usagetype', 'restartrequired', 'gpuenabled', 'displaynetwork', 'guestiptype', 'usersource', 'arch', 'oscategoryid', 'templatetype', 'gpucardid', 'vgpuprofileid', - 'extensionid', 'backupoffering', 'volumeid', 'virtualmachineid'].includes(item) + 'extensionid', 'backupoffering', 'volumeid', 'virtualmachineid', 'hsmprofileid', 'kmskeyid'].includes(item) ) { type = 'list' } else if (item === 'tags') { @@ -516,6 +528,8 @@ export default { let gpuCardIndex = -1 let vgpuProfileIndex = -1 let extensionIndex = -1 + let hsmProfileIndex = -1 + let kmsKeyIndex = -1 if (arrayField.includes('type')) { if (this.$route.path === '/alert') { @@ -661,6 +675,18 @@ export default { promises.push(await this.fetchVolumes(searchKeyword)) } + if (arrayField.includes('hsmprofileid')) { + hsmProfileIndex = this.fields.findIndex(item => item.name === 'hsmprofileid') + this.fields[hsmProfileIndex].loading = true + promises.push(await this.fetchHSMProfiles(searchKeyword)) + } + + if (arrayField.includes('kmskeyid')) { + kmsKeyIndex = this.fields.findIndex(item => item.name === 'kmskeyid') + this.fields[kmsKeyIndex].loading = true + promises.push(await this.fetchKMSKeys(searchKeyword)) + } + Promise.all(promises).then(response => { if (typeIndex > -1) { const types = response.filter(item => item.type === 'type') @@ -805,6 +831,20 @@ export default { this.fields[virtualmachineIndex].opts = this.sortArray(virtualMachines[0].data) } } + + if (hsmProfileIndex > -1) { + const hsmProfiles = response.filter(item => item.type === 'hsmprofileid') + if (hsmProfiles && hsmProfiles.length > 0) { + this.fields[hsmProfileIndex].opts = this.sortArray(hsmProfiles[0].data) + } + } + + if (kmsKeyIndex > -1) { + const kmsKeys = response.filter(item => item.type === 'kmskeyid') + if (kmsKeys && kmsKeys.length > 0) { + this.fields[kmsKeyIndex].opts = this.sortArray(kmsKeys[0].data) + } + } }).finally(() => { if (typeIndex > -1) { this.fields[typeIndex].loading = false @@ -872,6 +912,12 @@ export default { if (virtualmachineIndex > -1) { this.fields[virtualmachineIndex].loading = false } + if (hsmProfileIndex > -1) { + this.fields[hsmProfileIndex].loading = false + } + if (kmsKeyIndex > -1) { + this.fields[kmsKeyIndex].loading = false + } if (Array.isArray(arrayField)) { this.fillFormFieldValues() } @@ -1590,6 +1636,32 @@ export default { }) }) }, + fetchHSMProfiles (searchKeyword) { + return new Promise((resolve, reject) => { + getAPI('listHSMProfiles', { listAll: true, keyword: searchKeyword }).then(json => { + const hsmProfiles = json.listhsmprofilesresponse.hsmprofile + resolve({ + type: 'hsmprofileid', + data: hsmProfiles || [] + }) + }).catch(error => { + reject(error.response.headers['x-description']) + }) + }) + }, + fetchKMSKeys (searchKeyword) { + return new Promise((resolve, reject) => { + getAPI('listKMSKeys', { listAll: true, keyword: searchKeyword }).then(json => { + const kmsKeys = json.listkmskeysresponse.kmskey + resolve({ + type: 'kmskeyid', + data: kmsKeys || [] + }) + }).catch(error => { + reject(error.response.headers['x-description']) + }) + }) + }, onSearch (value) { this.paramsFilter = {} this.searchQuery = value diff --git a/ui/src/components/view/VolumesTab.vue b/ui/src/components/view/VolumesTab.vue index bdd511ef1ec..b02932adea3 100644 --- a/ui/src/components/view/VolumesTab.vue +++ b/ui/src/components/view/VolumesTab.vue @@ -80,7 +80,7 @@ export default { return { vm: {}, volumes: [], - defaultColumns: ['name', 'state', 'type', 'size'], + defaultColumns: ['name', 'state', 'type', 'size', 'kmskey'], allColumns: [ { key: 'name', @@ -101,6 +101,11 @@ export default { title: this.$t('label.size'), dataIndex: 'size' }, + { + key: 'kmskey', + title: this.$t('label.kms.key'), + dataIndex: 'kmskey' + }, { key: 'storage', title: this.$t('label.storage'), diff --git a/ui/src/components/widgets/DetailsInput.vue b/ui/src/components/widgets/DetailsInput.vue index a8d39fce02b..6d698e643c1 100644 --- a/ui/src/components/widgets/DetailsInput.vue +++ b/ui/src/components/widgets/DetailsInput.vue @@ -19,7 +19,15 @@
    - + + @@ -82,6 +90,15 @@ export default { showTableHeaders: { type: Boolean, default: true + }, + optionalKeys: { + type: Array, + default: () => [] + } + }, + computed: { + optionalKeyOptions () { + return this.optionalKeys.map(k => ({ value: k })) } }, data () { diff --git a/ui/src/config/section/kms.js b/ui/src/config/section/kms.js index 340c0e10191..672d2e2bbff 100644 --- a/ui/src/config/section/kms.js +++ b/ui/src/config/section/kms.js @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +import { shallowRef, defineAsyncComponent } from 'vue' import store from '@/store' export default { @@ -23,20 +24,48 @@ export default { icon: 'hdd-outlined', children: [ { - name: 'KMS key', + name: 'kmskey', title: 'label.kms.keys', icon: 'file-text-outlined', permission: ['listKMSKeys'], resourceType: 'KMSKey', columns: () => { - const fields = ['name', 'state', 'account', 'domain', 'purpose'] + const fields = ['name', 'enabled', 'purpose', 'hsmprofile'] + if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { + fields.push('account') + } + if (store.getters.listAllProjects) { + fields.push('project') + } + fields.push('domain') return fields }, - details: ['id', 'name', 'description', 'state', 'account', 'domain', 'created'], + details: ['id', 'name', 'description', 'version', 'enabled', 'account', 'domain', 'project', 'created', 'hsmprofile'], + related: [ + { + name: 'volume', + title: 'label.volumes', + param: 'kmskeyid' + } + ], + tabs: [ + { + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'events', + resourceType: 'KmsKey', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { + return 'listEvents' in store.getters.apis + } + } + ], searchFilters: () => { - var filters = ['zoneid'] + var filters = ['zoneid', 'hsmprofileid'] if (store.getters.userInfo.roletype === 'Admin') { - filters.push('accountid', 'domainid') + filters.push('account', 'domainid', 'projectid') } return filters }, @@ -47,27 +76,65 @@ export default { label: 'label.create.kms.key', listView: true, popup: true, - dataView: true, + dataView: false, args: (record, store, group) => { - var fields = ['zoneid', 'name', 'description', 'purpose', 'hsmprofileid', 'keybits'] - return (['Admin'].includes(store.userInfo.roletype)) - ? fields.concat(['domainid', 'account']) : fields + return ['Admin'].includes(store.userInfo.roletype) + ? ['zoneid', 'domainid', 'account', 'projectid', 'name', 'description', 'hsmprofileid', 'keybits'] + : ['zoneid', 'name', 'description', 'hsmprofileid', 'keybits'] } }, { api: 'updateKMSKey', icon: 'edit-outlined', docHelp: 'adminguide/storage.html#lifecycle-operations', - label: 'label.update.kms.ket', + label: 'label.update.kms.key', dataView: true, popup: true, - args: ['id', 'name', 'description', 'state'], + args: ['id', 'name', 'description', 'enabled'], mapping: { id: { value: (record) => record.id } } }, + { + api: 'rotateKMSKey', + icon: 'sync-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.rotate.kms.key', + dataView: true, + popup: true, + args: ['id', 'keybits', 'hsmprofileid'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'migrateVolumesToKMS', + icon: 'swap-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.migrate.volumes.to.kms', + message: 'message.action.migrate.volumes.to.kms', + dataView: true, + popup: true, + show: (record, store) => { + return ['Admin'].includes(store.userInfo.roletype) + }, + args: (record, store) => { + var fields = ['zoneid', 'kmskeyid', 'volumeids'] + if (['Admin'].includes(store.userInfo.roletype)) { + fields = fields.concat(['account', 'domainid']) + } + return fields + }, + mapping: { + kmskeyid: { + value: (record) => record.id + } + } + }, { api: 'deleteKMSKey', icon: 'delete-outlined', @@ -88,16 +155,47 @@ export default { { name: 'hsmprofile', title: 'label.hsm.profile', - icon: 'file-text-outlined', + icon: 'safety-outlined', permission: ['listHSMProfiles'], resourceType: 'HSMProfile', columns: () => { - const fields = ['name', 'state'] + const fields = ['name', 'enabled'] + if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { + fields.push('account') + } + if (store.getters.listAllProjects) { + fields.push('project') + } + fields.push('domain') return fields }, - details: ['id', 'name', 'description', 'state', 'account', 'domain', 'created'], + details: ['id', 'name', 'description', 'enabled', 'account', 'domain', 'project', 'created', 'details'], + related: [ + { + name: 'kmskey', + title: 'label.kms.keys', + param: 'hsmprofileid' + } + ], + tabs: [ + { + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'events', + resourceType: 'HsmProfile', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { + return 'listEvents' in store.getters.apis + } + } + ], searchFilters: () => { var filters = ['zoneid'] + if (store.getters.userInfo.roletype === 'Admin') { + filters.push('account', 'domainid', 'projectid') + } return filters }, actions: [ @@ -107,10 +205,16 @@ export default { label: 'label.create.hsmprofile', listView: true, popup: true, - dataView: true, + dataView: false, args: (record, store, group) => { - return (['Admin'].includes(store.userInfo.roletype)) - ? ['zoneid', 'name', 'vendorname', 'domainid', 'accountid', 'details', 'protocol'] : ['zoneid', 'name', 'vendorname', 'details', 'protocol'] + return ['Admin'].includes(store.userInfo.roletype) + ? ['name', 'zoneid', 'vendorname', 'domainid', 'account', 'projectid', 'details', 'system'] + : ['name', 'zoneid', 'vendorname', 'details'] + }, + mapping: { + details: { + optionalKeys: ['pin', 'library', 'slot', 'token_label'] + } } }, { @@ -120,7 +224,7 @@ export default { label: 'label.update.hsm.profile', dataView: true, popup: true, - args: ['id', 'name', 'details', 'enabled'], + args: ['id', 'name', 'enabled'], mapping: { id: { value: (record) => record.id diff --git a/ui/src/config/section/storage.js b/ui/src/config/section/storage.js index 75432314b03..964c025ab5f 100644 --- a/ui/src/config/section/storage.js +++ b/ui/src/config/section/storage.js @@ -63,7 +63,7 @@ export default { return fields }, - details: ['name', 'id', 'type', 'storagetype', 'diskofferingdisplaytext', 'deviceid', 'sizegb', 'physicalsize', 'provisioningtype', 'utilization', 'diskkbsread', 'diskkbswrite', 'diskioread', 'diskiowrite', 'diskiopstotal', 'miniops', 'maxiops', 'path', 'deleteprotection'], + details: ['name', 'id', 'type', 'storagetype', 'diskofferingdisplaytext', 'kmskey', 'deviceid', 'sizegb', 'physicalsize', 'provisioningtype', 'utilization', 'diskkbsread', 'diskkbswrite', 'diskioread', 'diskiowrite', 'diskiopstotal', 'miniops', 'maxiops', 'path', 'deleteprotection'], related: [{ name: 'snapshot', title: 'label.snapshots', @@ -92,7 +92,7 @@ export default { } ], searchFilters: () => { - const filters = ['name', 'zoneid', 'domainid', 'account', 'state', 'tags', 'serviceofferingid', 'diskofferingid', 'isencrypted'] + const filters = ['name', 'zoneid', 'domainid', 'account', 'state', 'tags', 'serviceofferingid', 'diskofferingid', 'kmskeyid', 'isencrypted'] if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { filters.push('storageid') } @@ -221,6 +221,25 @@ export default { popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/storage/MigrateVolume.vue'))) }, + { + api: 'migrateVolumesToKMS', + icon: 'lock-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.migrate.volume.to.kms', + message: 'message.action.migrate.volume.to.kms', + dataView: true, + popup: true, + show: (record, store) => { + return record.encryptformat && !record.kmskeyid && + ['Ready', 'Allocated'].includes(record.state) + }, + args: ['kmskeyid'], + mapping: { + volumeids: { + value: (record) => { return record.id } + } + } + }, { api: 'changeOfferingForVolume', icon: 'swap-outlined', diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index f4aa842d8f2..3de206a8b68 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -486,7 +486,8 @@ + v-model:value="form[field.name]" + :optionalKeys="currentAction.mapping?.[field.name]?.optionalKeys || []" /> + + + + + + {{ key.name }} + + +

    + {{ $t('message.kms.key.optional') }} +

    +
    +