diff --git a/src/azure-cli/azure/cli/command_modules/acs/_consts.py b/src/azure-cli/azure/cli/command_modules/acs/_consts.py index 71cefedd269..9d5af0098f0 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_consts.py @@ -222,6 +222,9 @@ CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PUBLIC = "Public" CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE = "Private" +# kms infrastructure encryption (platform-managed keys) +CONST_KMS_INFRASTRUCTURE_ENCRYPTION_ENABLED = "Enabled" + # app routing nginx config options CONST_WEB_APPLICATION_ROUTING_KEY_NAME = "ingress/webApplicationRouting" CONST_APP_ROUTING_ANNOTATION_CONTROLLED_NGINX = "AnnotationControlled" diff --git a/src/azure-cli/azure/cli/command_modules/acs/_help.py b/src/azure-cli/azure/cli/command_modules/acs/_help.py index a1020fb25a4..a4b536cd1fd 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_help.py @@ -530,6 +530,10 @@ - name: --azure-keyvault-kms-key-vault-resource-id type: string short-summary: Resource ID of Azure Key Vault. + - name: --kms-infrastructure-encryption + type: string + short-summary: Enable encryption at rest of Kubernetes resource objects using service-managed keys. + long-summary: Enable infrastructure encryption for Kubernetes resource objects. This feature provides encryption at rest for cluster secrets and configuration using service-managed keys. The only accepted value is "Enabled"; once enabled it cannot be disabled. For more information see https://aka.ms/aks/kubernetesResourceObjectEncryption. - name: --enable-image-cleaner type: bool short-summary: Enable ImageCleaner Service. @@ -1080,6 +1084,10 @@ - name: --azure-keyvault-kms-key-vault-resource-id type: string short-summary: Resource ID of Azure Key Vault. + - name: --kms-infrastructure-encryption + type: string + short-summary: Enable encryption at rest of Kubernetes resource objects using service-managed keys. + long-summary: Enable infrastructure encryption for Kubernetes resource objects. This feature provides encryption at rest for cluster secrets and configuration using service-managed keys. The only accepted value is "Enabled"; once enabled it cannot be disabled. For more information see https://aka.ms/aks/kubernetesResourceObjectEncryption. - name: --enable-image-cleaner type: bool short-summary: Enable ImageCleaner Service. diff --git a/src/azure-cli/azure/cli/command_modules/acs/_params.py b/src/azure-cli/azure/cli/command_modules/acs/_params.py index dea20bf437c..603b318eeb3 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_params.py @@ -12,6 +12,7 @@ from azure.cli.command_modules.acs._consts import ( CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE, CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PUBLIC, + CONST_KMS_INFRASTRUCTURE_ENCRYPTION_ENABLED, CONST_GPU_INSTANCE_PROFILE_MIG1_G, CONST_GPU_INSTANCE_PROFILE_MIG2_G, CONST_GPU_INSTANCE_PROFILE_MIG3_G, CONST_GPU_INSTANCE_PROFILE_MIG4_G, CONST_GPU_INSTANCE_PROFILE_MIG7_G, CONST_LOAD_BALANCER_SKU_BASIC, @@ -262,6 +263,7 @@ dev_space_endpoint_types = ['Public', 'Private', 'None'] keyvault_network_access_types = [CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PUBLIC, CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE] +kms_infrastructure_encryption_types = [CONST_KMS_INFRASTRUCTURE_ENCRYPTION_ENABLED] gpu_instance_profiles = [ CONST_GPU_INSTANCE_PROFILE_MIG1_G, @@ -514,6 +516,7 @@ def load_arguments(self, _): c.argument('azure_keyvault_kms_key_id', validator=validate_azure_keyvault_kms_key_id) c.argument('azure_keyvault_kms_key_vault_network_access', arg_type=get_enum_type(keyvault_network_access_types)) c.argument('azure_keyvault_kms_key_vault_resource_id', validator=validate_azure_keyvault_kms_key_vault_resource_id) + c.argument('kms_infrastructure_encryption', arg_type=get_enum_type(kms_infrastructure_encryption_types)) c.argument('enable_image_cleaner', action='store_true') c.argument('image_cleaner_interval_hours', type=int) c.argument('http_proxy_config') @@ -855,6 +858,7 @@ def load_arguments(self, _): c.argument('azure_keyvault_kms_key_id', validator=validate_azure_keyvault_kms_key_id) c.argument('azure_keyvault_kms_key_vault_network_access', arg_type=get_enum_type(keyvault_network_access_types)) c.argument('azure_keyvault_kms_key_vault_resource_id', validator=validate_azure_keyvault_kms_key_vault_resource_id) + c.argument('kms_infrastructure_encryption', arg_type=get_enum_type(kms_infrastructure_encryption_types)) c.argument('enable_image_cleaner', action='store_true') c.argument('disable_image_cleaner', action='store_true', validator=validate_image_cleaner_enable_disable_mutually_exclusive) c.argument('image_cleaner_interval_hours', type=int) diff --git a/src/azure-cli/azure/cli/command_modules/acs/_validators.py b/src/azure-cli/azure/cli/command_modules/acs/_validators.py index bab4888fc5f..67e4e9234b4 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_validators.py @@ -870,8 +870,11 @@ def validate_azure_keyvault_kms_key_id(namespace): if not key_id.startswith(https_prefix): raise InvalidArgumentValueError(err_msg) + # A versioned key ID has 4 segments ({vault-url}/keys/{key-name}/{key-version}), while a versionless + # key ID has 3 segments ({vault-url}/keys/{key-name}). Versionless key IDs are required when KMS + # infrastructure encryption (platform-managed keys) is enabled. Any other number of segments is invalid. segments = key_id[len(https_prefix):].split("/") - if len(segments) != 4 or segments[1] != "keys": + if len(segments) not in (3, 4) or segments[1] != "keys": raise InvalidArgumentValueError(err_msg) @@ -879,10 +882,31 @@ def validate_azure_keyvault_kms_key_vault_resource_id(namespace): key_vault_resource_id = namespace.azure_keyvault_kms_key_vault_resource_id if key_vault_resource_id is None or key_vault_resource_id == '': return - from azure.mgmt.core.tools import is_valid_resource_id + from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id if not is_valid_resource_id(key_vault_resource_id): raise InvalidArgumentValueError("--azure-keyvault-kms-key-vault-resource-id is not a valid Azure resource ID.") + try: + parsed = parse_resource_id(key_vault_resource_id) + provider = parsed.get('namespace', '').lower() + if provider != 'microsoft.keyvault': + raise InvalidArgumentValueError( + "--azure-keyvault-kms-key-vault-resource-id must reference a Microsoft.KeyVault resource." + ) + resource_type = parsed.get('type', '').lower() + if resource_type not in ['vaults', 'managedhsms']: + raise InvalidArgumentValueError( + "--azure-keyvault-kms-key-vault-resource-id must reference a Key Vault " + "(vaults) or Managed HSM (managedHSMs)." + ) + except InvalidArgumentValueError: + # Re-raise our validation errors + raise + except Exception as ex: + raise InvalidArgumentValueError( + f"--azure-keyvault-kms-key-vault-resource-id parsing failed: {str(ex)}" + ) + def validate_image_cleaner_enable_disable_mutually_exclusive(namespace): enable_image_cleaner = namespace.enable_image_cleaner diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index 70bf1c416a8..99b10394c52 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -938,6 +938,7 @@ def aks_create( azure_keyvault_kms_key_id=None, azure_keyvault_kms_key_vault_network_access=None, azure_keyvault_kms_key_vault_resource_id=None, + kms_infrastructure_encryption=None, enable_image_cleaner=False, image_cleaner_interval_hours=None, enable_keda=False, @@ -1191,6 +1192,7 @@ def aks_update( azure_keyvault_kms_key_id=None, azure_keyvault_kms_key_vault_network_access=None, azure_keyvault_kms_key_vault_resource_id=None, + kms_infrastructure_encryption=None, enable_image_cleaner=False, disable_image_cleaner=False, image_cleaner_interval_hours=None, diff --git a/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml b/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml index 4530e07e60f..949010910cf 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml +++ b/src/azure-cli/azure/cli/command_modules/acs/linter_exclusions.yml @@ -127,6 +127,9 @@ aks create: enable_upstream_kubescheduler_user_configuration: rule_exclusions: - option_length_too_long + kms_infrastructure_encryption: + rule_exclusions: + - option_length_too_long aks enable-addons: parameters: appgw_watch_namespace: @@ -287,6 +290,9 @@ aks update: disable_upstream_kubescheduler_user_configuration: rule_exclusions: - option_length_too_long + kms_infrastructure_encryption: + rule_exclusions: + - option_length_too_long aks nodepool add: parameters: disable_windows_outbound_nat: diff --git a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py index 9233be1f48b..0fa7a008657 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py +++ b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py @@ -33,7 +33,7 @@ CONST_PRIVATE_DNS_ZONE_NONE, CONST_PRIVATE_DNS_ZONE_SYSTEM, CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE, - CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PUBLIC, + CONST_KMS_INFRASTRUCTURE_ENCRYPTION_ENABLED, AgentPoolDecoratorMode, DecoratorEarlyExitException, DecoratorMode, @@ -5845,6 +5845,28 @@ def get_workload_identity_profile(self) -> Optional[ManagedClusterSecurityProfil return profile + def get_kms_infrastructure_encryption(self) -> Union[str, None]: + """Obtain the value of kms_infrastructure_encryption. + + :return: string or None + """ + # read the original value passed by the command + kms_infrastructure_encryption = self.raw_param.get("kms_infrastructure_encryption") + + # try to read the property value corresponding to the parameter from the `mc` object + if kms_infrastructure_encryption is None and self.mc: + security_profile = getattr(self.mc, "security_profile", None) + if security_profile: + encryption_profile = getattr( + security_profile, "kubernetes_resource_object_encryption_profile", None + ) + if encryption_profile: + infrastructure_encryption = getattr(encryption_profile, "infrastructure_encryption", None) + if infrastructure_encryption is not None: + kms_infrastructure_encryption = infrastructure_encryption + + return kms_infrastructure_encryption + def _get_enable_azure_keyvault_kms(self, enable_validation: bool = False) -> bool: """Internal function to obtain the value of enable_azure_keyvault_kms. @@ -6047,6 +6069,9 @@ def _get_azure_keyvault_kms_key_vault_resource_id(self, enable_validation: bool # validation if enable_validation: + is_pmk_enabled = ( + self.get_kms_infrastructure_encryption() == CONST_KMS_INFRASTRUCTURE_ENCRYPTION_ENABLED + ) enable_azure_keyvault_kms = self._get_enable_azure_keyvault_kms( enable_validation=False) if ( @@ -6060,29 +6085,10 @@ def _get_azure_keyvault_kms_key_vault_resource_id(self, enable_validation: bool '"--azure-keyvault-kms-key-vault-resource-id" requires "--enable-azure-keyvault-kms".' ) - key_vault_network_access = self._get_azure_keyvault_kms_key_vault_network_access( - enable_validation=False) - if ( - key_vault_network_access == CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE and - ( - azure_keyvault_kms_key_vault_resource_id is None or - azure_keyvault_kms_key_vault_resource_id == "" - ) - ): - raise ArgumentUsageError( - '"--azure-keyvault-kms-key-vault-resource-id" can not be empty if ' - '"--azure-keyvault-kms-key-vault-network-access" is "Private".' - ) - if ( - key_vault_network_access == CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PUBLIC and - ( - azure_keyvault_kms_key_vault_resource_id is not None and - azure_keyvault_kms_key_vault_resource_id != "" - ) - ): - raise ArgumentUsageError( - '"--azure-keyvault-kms-key-vault-resource-id" must be empty if ' - '"--azure-keyvault-kms-key-vault-network-access" is "Public".' + if is_pmk_enabled and not azure_keyvault_kms_key_vault_resource_id: + raise RequiredArgumentMissingError( + '"--azure-keyvault-kms-key-vault-resource-id" is required when ' + '"--kms-infrastructure-encryption" is set to "Enabled".' ) return azure_keyvault_kms_key_vault_resource_id @@ -7238,12 +7244,25 @@ def set_up_defender(self, mc: ManagedCluster) -> ManagedCluster: return mc def set_up_azure_keyvault_kms(self, mc: ManagedCluster) -> ManagedCluster: - """Set up security profile azureKeyVaultKms for the ManagedCluster object. + """Set up security profile kubernetesResourceObjectEncryptionProfile and azureKeyVaultKms for + the ManagedCluster object. :return: the ManagedCluster object """ self._ensure_mc(mc) + kms_infrastructure_encryption = self.context.get_kms_infrastructure_encryption() + if kms_infrastructure_encryption: + if mc.security_profile is None: + mc.security_profile = self.models.ManagedClusterSecurityProfile() + if mc.security_profile.kubernetes_resource_object_encryption_profile is None: + mc.security_profile.kubernetes_resource_object_encryption_profile = ( + self.models.KubernetesResourceObjectEncryptionProfile() + ) + mc.security_profile.kubernetes_resource_object_encryption_profile.infrastructure_encryption = ( + kms_infrastructure_encryption + ) + if self.context.get_enable_azure_keyvault_kms(): key_id = self.context.get_azure_keyvault_kms_key_id() if key_id: @@ -7255,10 +7274,9 @@ def set_up_azure_keyvault_kms(self, mc: ManagedCluster) -> ManagedCluster: ) key_vault_network_access = self.context.get_azure_keyvault_kms_key_vault_network_access() mc.security_profile.azure_key_vault_kms.key_vault_network_access = key_vault_network_access - if key_vault_network_access == CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE: - mc.security_profile.azure_key_vault_kms.key_vault_resource_id = ( - self.context.get_azure_keyvault_kms_key_vault_resource_id() - ) + mc.security_profile.azure_key_vault_kms.key_vault_resource_id = ( + self.context.get_azure_keyvault_kms_key_vault_resource_id() + ) return mc @@ -10410,12 +10428,25 @@ def update_k8s_support_plan(self, mc: ManagedCluster) -> ManagedCluster: return mc def update_azure_keyvault_kms(self, mc: ManagedCluster) -> ManagedCluster: - """Update security profile azureKeyvaultKms for the ManagedCluster object. + """Update security profile kubernetesResourceObjectEncryptionProfile and azureKeyvaultKms for + the ManagedCluster object. :return: the ManagedCluster object """ self._ensure_mc(mc) + kms_infrastructure_encryption = self.context.get_kms_infrastructure_encryption() + if kms_infrastructure_encryption: + if mc.security_profile is None: + mc.security_profile = self.models.ManagedClusterSecurityProfile() + if mc.security_profile.kubernetes_resource_object_encryption_profile is None: + mc.security_profile.kubernetes_resource_object_encryption_profile = ( + self.models.KubernetesResourceObjectEncryptionProfile() + ) + mc.security_profile.kubernetes_resource_object_encryption_profile.infrastructure_encryption = ( + kms_infrastructure_encryption + ) + if self.context.get_enable_azure_keyvault_kms(): # get kms profile if mc.security_profile is None: @@ -10434,13 +10465,11 @@ def update_azure_keyvault_kms(self, mc: ManagedCluster) -> ManagedCluster: azure_key_vault_kms_profile.key_vault_network_access = ( self.context.get_azure_keyvault_kms_key_vault_network_access() ) - # set key vault resource id - if azure_key_vault_kms_profile.key_vault_network_access == CONST_AZURE_KEYVAULT_NETWORK_ACCESS_PRIVATE: - azure_key_vault_kms_profile.key_vault_resource_id = ( - self.context.get_azure_keyvault_kms_key_vault_resource_id() - ) - else: - azure_key_vault_kms_profile.key_vault_resource_id = "" + # set key vault resource id, it is required when network access is "Private" and when + # infrastructure encryption (PMK) is enabled; fall back to "" so that it is cleared otherwise + azure_key_vault_kms_profile.key_vault_resource_id = ( + self.context.get_azure_keyvault_kms_key_vault_resource_id() or "" + ) if self.context.get_disable_azure_keyvault_kms(): # get kms profile diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py index d2acece19d3..ddacbcab2b4 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py @@ -11477,6 +11477,236 @@ def test_aks_disable_azurekeyvaultkms(self, resource_group, resource_group_locat self.is_empty(), ]) + @live_only() + @AllowLargeResponse() + @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='westcentralus') + def test_aks_create_with_kms_pmk_and_update_cmk(self, resource_group, resource_group_location): + """Create a PMK-enabled cluster, then enable and disable CMK on it.""" + aks_name = self.create_random_name('cliakstest', 16) + kv_name = self.create_random_name('cliakstestkv', 16) + identity_name = self.create_random_name('cliakstestidentity', 24) + self.kwargs.update({ + 'resource_group': resource_group, + 'name': aks_name, + 'kv_name': kv_name, + 'identity_name': identity_name, + 'ssh_key_value': self.generate_ssh_keys(), + }) + + # create user-assigned identity + identity_id = self._get_user_assigned_identity(resource_group) + identity_object_id = self._get_principal_id_of_user_assigned_identity(identity_id) + assert identity_id is not None + assert identity_object_id is not None + self.kwargs.update({ + 'identity_id': identity_id, + 'identity_object_id': identity_object_id, + }) + + # create cluster with KMS infrastructure encryption (PMK) enabled + create_cmd = 'aks create --resource-group={resource_group} --name={name} ' \ + '--assign-identity {identity_id} ' \ + '--kms-infrastructure-encryption Enabled ' \ + '--ssh-key-value={ssh_key_value} -o json' + self.cmd(create_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption', + 'Enabled'), + self.not_exists('securityProfile.azureKeyVaultKms'), + ]) + + # create key vault and key for CMK + create_keyvault = 'keyvault create --resource-group={resource_group} --name={kv_name} ' \ + '--enable-rbac-authorization=false --no-self-perms -o json' + self.cmd(create_keyvault, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + # set access policy for test identity + test_identity_object_id = self._get_test_identity_object_id() + test_identity_access_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \ + '--key-permissions all --object-id ' + test_identity_object_id + self.cmd(test_identity_access_policy, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + create_key = 'keyvault key create -n kms --vault-name {kv_name} -o json' + key = self.cmd(create_key, checks=[ + self.check('attributes.enabled', True) + ]).get_output_in_json() + # PMK requires a versionless key ID, drop the trailing version segment + key_id_versionless = key['key']['kid'].rsplit('/', 1)[0] + assert key_id_versionless is not None + + kv_resource_id = self.cmd( + 'keyvault show --resource-group={resource_group} --name={kv_name} --query id -o tsv' + ).output.strip() + self.kwargs.update({ + 'key_id': key_id_versionless, + 'kv_resource_id': kv_resource_id, + }) + + # assign access policy for the cluster identity + set_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \ + '--object-id {identity_object_id} --key-permissions encrypt decrypt -o json' + self.cmd(set_policy, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + # enable CMK on the existing PMK cluster + update_cmd = 'aks update --resource-group={resource_group} --name={name} ' \ + '--enable-azure-keyvault-kms --azure-keyvault-kms-key-id={key_id} ' \ + '--azure-keyvault-kms-key-vault-network-access=Public ' \ + '--azure-keyvault-kms-key-vault-resource-id={kv_resource_id} -o json' + self.cmd(update_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('securityProfile.azureKeyVaultKms.enabled', True), + self.check('securityProfile.azureKeyVaultKms.keyId', key_id_versionless), + self.check('securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption', + 'Enabled'), + ]) + + # enabling CMK triggers an async re-encryption of the cluster's Kubernetes secrets, + # wait for the cluster to settle before disabling CMK + self.cmd('aks wait --resource-group={resource_group} --name={name} --updated ' + '--interval 30 --timeout 1800', checks=[self.is_empty()]) + + # disable CMK, PMK is preserved + update_cmd = 'aks update --resource-group={resource_group} --name={name} ' \ + '--disable-azure-keyvault-kms -o json' + self.cmd(update_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('securityProfile.azureKeyVaultKms.enabled', False), + self.check('securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption', + 'Enabled'), + ]) + + # delete + cmd = 'aks delete --resource-group={resource_group} --name={name} --yes --no-wait' + self.cmd(cmd, checks=[ + self.is_empty(), + ]) + + @live_only() + @AllowLargeResponse() + @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='westcentralus') + def test_aks_create_with_kms_pmk_and_cmk(self, resource_group, resource_group_location): + """Create a cluster with both PMK and CMK enabled at the same time.""" + aks_name = self.create_random_name('cliakstest', 16) + kv_name = self.create_random_name('cliakstestkv', 16) + identity_name = self.create_random_name('cliakstestidentity', 24) + self.kwargs.update({ + 'resource_group': resource_group, + 'name': aks_name, + 'kv_name': kv_name, + 'identity_name': identity_name, + 'ssh_key_value': self.generate_ssh_keys(), + }) + + # create user-assigned identity + identity_id = self._get_user_assigned_identity(resource_group) + identity_object_id = self._get_principal_id_of_user_assigned_identity(identity_id) + assert identity_id is not None + assert identity_object_id is not None + self.kwargs.update({ + 'identity_id': identity_id, + 'identity_object_id': identity_object_id, + }) + + # create key vault and key + create_keyvault = 'keyvault create --resource-group={resource_group} --name={kv_name} ' \ + '--enable-rbac-authorization=false --no-self-perms -o json' + self.cmd(create_keyvault, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + # set access policy for test identity + test_identity_object_id = self._get_test_identity_object_id() + test_identity_access_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \ + '--key-permissions all --object-id ' + test_identity_object_id + self.cmd(test_identity_access_policy, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + create_key = 'keyvault key create -n kms --vault-name {kv_name} -o json' + key = self.cmd(create_key, checks=[ + self.check('attributes.enabled', True) + ]).get_output_in_json() + # PMK requires a versionless key ID, drop the trailing version segment + key_id_versionless = key['key']['kid'].rsplit('/', 1)[0] + assert key_id_versionless is not None + + kv_resource_id = self.cmd( + 'keyvault show --resource-group={resource_group} --name={kv_name} --query id -o tsv' + ).output.strip() + self.kwargs.update({ + 'key_id': key_id_versionless, + 'kv_resource_id': kv_resource_id, + }) + + # assign access policy for the cluster identity + set_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \ + '--object-id {identity_object_id} --key-permissions encrypt decrypt -o json' + self.cmd(set_policy, checks=[ + self.check('properties.provisioningState', 'Succeeded') + ]) + + create_cmd = 'aks create --resource-group={resource_group} --name={name} ' \ + '--assign-identity {identity_id} ' \ + '--enable-azure-keyvault-kms --azure-keyvault-kms-key-id={key_id} ' \ + '--azure-keyvault-kms-key-vault-network-access=Public ' \ + '--azure-keyvault-kms-key-vault-resource-id={kv_resource_id} ' \ + '--kms-infrastructure-encryption=Enabled ' \ + '--ssh-key-value={ssh_key_value} -o json' + self.cmd(create_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('securityProfile.azureKeyVaultKms.enabled', True), + self.check('securityProfile.azureKeyVaultKms.keyId', key_id_versionless), + self.check('securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption', + 'Enabled'), + ]) + + # delete + cmd = 'aks delete --resource-group={resource_group} --name={name} --yes --no-wait' + self.cmd(cmd, checks=[ + self.is_empty(), + ]) + + @live_only() + @AllowLargeResponse() + @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='westcentralus') + def test_aks_update_with_kms_pmk(self, resource_group, resource_group_location): + """Enable KMS infrastructure encryption (PMK) on an existing cluster.""" + aks_name = self.create_random_name('cliakstest', 16) + self.kwargs.update({ + 'resource_group': resource_group, + 'name': aks_name, + 'ssh_key_value': self.generate_ssh_keys(), + }) + + # create cluster without infrastructure encryption + create_cmd = 'aks create --resource-group={resource_group} --name={name} ' \ + '--ssh-key-value={ssh_key_value} -o json' + self.cmd(create_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.not_exists('securityProfile.kubernetesResourceObjectEncryptionProfile'), + ]) + + # update cluster to enable infrastructure encryption + update_cmd = 'aks update --resource-group={resource_group} --name={name} ' \ + '--kms-infrastructure-encryption Enabled -o json' + self.cmd(update_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + self.check('securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption', + 'Enabled'), + ]) + + # delete + cmd = 'aks delete --resource-group={resource_group} --name={name} --yes --no-wait' + self.cmd(cmd, checks=[ + self.is_empty(), + ]) + @AllowLargeResponse() @AKSCustomResourceGroupPreparer(random_name_length=17, name_prefix='clitest', location='westus2') def test_aks_create_with_network_plugin_none(self, resource_group, resource_group_location): diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py index c06b203f6cb..02a3af58251 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py @@ -5998,6 +5998,7 @@ def test_get_azure_keyvault_kms_key_vault_resource_id(self): with self.assertRaises(RequiredArgumentMissingError): ctx_7.get_azure_keyvault_kms_key_vault_resource_id() + # key vault resource id is allowed with "Public" network access ctx_8 = AKSManagedClusterContext( self.cmd, AKSManagedClusterParamDict({ @@ -6008,9 +6009,10 @@ def test_get_azure_keyvault_kms_key_vault_resource_id(self): self.models, decorator_mode=DecoratorMode.CREATE, ) - with self.assertRaises(ArgumentUsageError): - ctx_8.get_azure_keyvault_kms_key_vault_resource_id() + self.assertEqual(ctx_8.get_azure_keyvault_kms_key_vault_resource_id(), key_vault_resource_id_1) + # "Private" network access without a key vault resource id is rejected by + # get_azure_keyvault_kms_key_vault_network_access ctx_9 = AKSManagedClusterContext( self.cmd, AKSManagedClusterParamDict({ @@ -6021,8 +6023,79 @@ def test_get_azure_keyvault_kms_key_vault_resource_id(self): self.models, decorator_mode=DecoratorMode.CREATE, ) - with self.assertRaises(ArgumentUsageError): - ctx_9.get_azure_keyvault_kms_key_vault_resource_id() + self.assertEqual(ctx_9.get_azure_keyvault_kms_key_vault_resource_id(), "") + with self.assertRaises(RequiredArgumentMissingError): + ctx_9.get_azure_keyvault_kms_key_vault_network_access() + + # PMK enabled: key vault resource id is required + ctx_10 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({ + "enable_azure_keyvault_kms": True, + "azure_keyvault_kms_key_vault_network_access": "Public", + "kms_infrastructure_encryption": "Enabled", + }), + self.models, + decorator_mode=DecoratorMode.CREATE, + ) + with self.assertRaises(RequiredArgumentMissingError): + ctx_10.get_azure_keyvault_kms_key_vault_resource_id() + + # PMK enabled: key vault resource id is allowed with "Public" network access + ctx_11 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({ + "enable_azure_keyvault_kms": True, + "azure_keyvault_kms_key_vault_network_access": "Public", + "azure_keyvault_kms_key_vault_resource_id": key_vault_resource_id_1, + "kms_infrastructure_encryption": "Enabled", + }), + self.models, + decorator_mode=DecoratorMode.CREATE, + ) + self.assertEqual(ctx_11.get_azure_keyvault_kms_key_vault_resource_id(), key_vault_resource_id_1) + + def test_get_kms_infrastructure_encryption(self): + ctx_0 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({}), + self.models, + decorator_mode=DecoratorMode.CREATE, + ) + self.assertIsNone(ctx_0.get_kms_infrastructure_encryption()) + + ctx_1 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({"kms_infrastructure_encryption": "Enabled"}), + self.models, + decorator_mode=DecoratorMode.CREATE, + ) + self.assertEqual(ctx_1.get_kms_infrastructure_encryption(), "Enabled") + + # backfill from the existing mc + ctx_2 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({"kms_infrastructure_encryption": None}), + self.models, + decorator_mode=DecoratorMode.UPDATE, + ) + security_profile = self.models.ManagedClusterSecurityProfile() + security_profile.kubernetes_resource_object_encryption_profile = ( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ) + mc = self.models.ManagedCluster(location="test_location", security_profile=security_profile) + ctx_2.attach_mc(mc) + self.assertEqual(ctx_2.get_kms_infrastructure_encryption(), "Enabled") + + # the value passed by the command takes precedence + ctx_3 = AKSManagedClusterContext( + self.cmd, + AKSManagedClusterParamDict({"kms_infrastructure_encryption": "Enabled"}), + self.models, + decorator_mode=DecoratorMode.UPDATE, + ) + ctx_3.attach_mc(self.models.ManagedCluster(location="test_location")) + self.assertEqual(ctx_3.get_kms_infrastructure_encryption(), "Enabled") def test_get_enable_image_cleaner(self): ctx_0 = AKSManagedClusterContext( @@ -9875,6 +9948,119 @@ def test_set_up_http_proxy_config(self): ) self.assertEqual(dec_mc_1, ground_truth_mc_1) + def test_set_up_azure_keyvault_kms(self): + key_id = "https://fakekeyvault.vault.azure.net/keys/fakekeyname" + key_vault_resource_id = ( + "/subscriptions/8ecadfc9-d1a3-4ea4-b844-0d9f87e4d7c8/resourceGroups/foo/" + "providers/Microsoft.KeyVault/vaults/foo" + ) + + # no kms related changes + dec_0 = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + {}, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_0 = self.models.ManagedCluster(location="test_location") + dec_0.context.attach_mc(mc_0) + dec_mc_0 = dec_0.set_up_azure_keyvault_kms(mc_0) + self.assertEqual(dec_mc_0, self.models.ManagedCluster(location="test_location")) + + # pmk only + dec_1 = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + { + "kms_infrastructure_encryption": "Enabled", + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_1 = self.models.ManagedCluster(location="test_location") + dec_1.context.attach_mc(mc_1) + dec_mc_1 = dec_1.set_up_azure_keyvault_kms(mc_1) + ground_truth_mc_1 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + ), + ) + self.assertEqual(dec_mc_1, ground_truth_mc_1) + + # unset pmk should not set the encryption profile + dec_2 = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + { + "kms_infrastructure_encryption": None, + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_2 = self.models.ManagedCluster(location="test_location") + dec_2.context.attach_mc(mc_2) + dec_mc_2 = dec_2.set_up_azure_keyvault_kms(mc_2) + self.assertEqual(dec_mc_2, self.models.ManagedCluster(location="test_location")) + + # pmk and cmk, the key vault resource id is set even with "Public" network access + dec_3 = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + { + "kms_infrastructure_encryption": "Enabled", + "enable_azure_keyvault_kms": True, + "azure_keyvault_kms_key_id": key_id, + "azure_keyvault_kms_key_vault_network_access": "Public", + "azure_keyvault_kms_key_vault_resource_id": key_vault_resource_id, + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_3 = self.models.ManagedCluster(location="test_location") + dec_3.context.attach_mc(mc_3) + dec_mc_3 = dec_3.set_up_azure_keyvault_kms(mc_3) + ground_truth_mc_3 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + azure_key_vault_kms=self.models.AzureKeyVaultKms( + enabled=True, + key_id=key_id, + key_vault_network_access="Public", + key_vault_resource_id=key_vault_resource_id, + ), + ), + ) + self.assertEqual(dec_mc_3, ground_truth_mc_3) + + # cmk only with "Public" network access, the key vault resource id is not set + dec_4 = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + { + "enable_azure_keyvault_kms": True, + "azure_keyvault_kms_key_id": key_id, + "azure_keyvault_kms_key_vault_network_access": "Public", + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_4 = self.models.ManagedCluster(location="test_location") + dec_4.context.attach_mc(mc_4) + dec_mc_4 = dec_4.set_up_azure_keyvault_kms(mc_4) + ground_truth_mc_4 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + azure_key_vault_kms=self.models.AzureKeyVaultKms( + enabled=True, + key_id=key_id, + key_vault_network_access="Public", + ), + ), + ) + self.assertEqual(dec_mc_4, ground_truth_mc_4) + def test_set_up_image_cleaner(self): dec_0 = AKSManagedClusterCreateDecorator( self.cmd, @@ -12000,6 +12186,118 @@ def test_update_azure_keyvault_secrets_provider_addon_profile(self): ground_truth_azure_keyvault_secrets_provider_addon_profile_5, ) + def test_update_azure_keyvault_kms(self): + key_id = "https://fakekeyvault.vault.azure.net/keys/fakekeyname" + key_vault_resource_id = ( + "/subscriptions/8ecadfc9-d1a3-4ea4-b844-0d9f87e4d7c8/resourceGroups/foo/" + "providers/Microsoft.KeyVault/vaults/foo" + ) + + # no kms related changes + dec_0 = AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + {}, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_0 = self.models.ManagedCluster(location="test_location") + dec_0.context.attach_mc(mc_0) + dec_mc_0 = dec_0.update_azure_keyvault_kms(mc_0) + self.assertEqual(dec_mc_0, self.models.ManagedCluster(location="test_location")) + + # enable pmk only + dec_1 = AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + { + "kms_infrastructure_encryption": "Enabled", + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_1 = self.models.ManagedCluster(location="test_location") + dec_1.context.attach_mc(mc_1) + dec_mc_1 = dec_1.update_azure_keyvault_kms(mc_1) + ground_truth_mc_1 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + ), + ) + self.assertEqual(dec_mc_1, ground_truth_mc_1) + + # enable pmk and cmk, the key vault resource id is kept with "Public" network access + dec_2 = AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + { + "kms_infrastructure_encryption": "Enabled", + "enable_azure_keyvault_kms": True, + "azure_keyvault_kms_key_id": key_id, + "azure_keyvault_kms_key_vault_network_access": "Public", + "azure_keyvault_kms_key_vault_resource_id": key_vault_resource_id, + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_2 = self.models.ManagedCluster(location="test_location") + dec_2.context.attach_mc(mc_2) + dec_mc_2 = dec_2.update_azure_keyvault_kms(mc_2) + ground_truth_mc_2 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + azure_key_vault_kms=self.models.AzureKeyVaultKms( + enabled=True, + key_id=key_id, + key_vault_network_access="Public", + key_vault_resource_id=key_vault_resource_id, + ), + ), + ) + self.assertEqual(dec_mc_2, ground_truth_mc_2) + + # disable cmk, the pmk profile is preserved + dec_3 = AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + { + "disable_azure_keyvault_kms": True, + }, + ResourceType.MGMT_CONTAINERSERVICE, + ) + mc_3 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + azure_key_vault_kms=self.models.AzureKeyVaultKms( + enabled=True, + key_id=key_id, + key_vault_network_access="Public", + ), + ), + ) + dec_3.context.attach_mc(mc_3) + dec_mc_3 = dec_3.update_azure_keyvault_kms(mc_3) + ground_truth_mc_3 = self.models.ManagedCluster( + location="test_location", + security_profile=self.models.ManagedClusterSecurityProfile( + kubernetes_resource_object_encryption_profile=( + self.models.KubernetesResourceObjectEncryptionProfile(infrastructure_encryption="Enabled") + ), + azure_key_vault_kms=self.models.AzureKeyVaultKms( + enabled=False, + key_id=key_id, + key_vault_network_access="Public", + ), + ), + ) + self.assertEqual(dec_mc_3, ground_truth_mc_3) + def test_update_image_cleaner(self): dec_0 = AKSManagedClusterUpdateDecorator( self.cmd, diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py index f84f4e107ad..6a71d976116 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py @@ -634,8 +634,26 @@ def test_invalid_azure_keyvault_kms_key_id_without_https(self): validators.validate_azure_keyvault_kms_key_id(namespace) self.assertEqual(str(cm.exception), err) - def test_invalid_azure_keyvault_kms_key_id_without_key_version(self): - invalid_azure_keyvault_kms_key_id = "https://fakekeyvault.vault.azure.net/keys/fakekeyname" + def test_valid_azure_keyvault_kms_key_id_without_key_version(self): + # versionless key IDs are valid, they are required when KMS infrastructure encryption (PMK) is enabled + valid_azure_keyvault_kms_key_id = "https://fakekeyvault.vault.azure.net/keys/fakekeyname" + namespace = AzureKeyVaultKmsKeyIdNamespace(azure_keyvault_kms_key_id=valid_azure_keyvault_kms_key_id) + + validators.validate_azure_keyvault_kms_key_id(namespace) + + def test_invalid_azure_keyvault_kms_key_id_with_insufficient_segments(self): + invalid_azure_keyvault_kms_key_id = "https://fakekeyvault.vault.azure.net/keys" + namespace = AzureKeyVaultKmsKeyIdNamespace(azure_keyvault_kms_key_id=invalid_azure_keyvault_kms_key_id) + err = '--azure-keyvault-kms-key-id is not a valid Key Vault key ID. ' \ + 'See https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name' + + with self.assertRaises(CLIError) as cm: + validators.validate_azure_keyvault_kms_key_id(namespace) + self.assertEqual(str(cm.exception), err) + + def test_invalid_azure_keyvault_kms_key_id_with_extra_segments(self): + invalid_azure_keyvault_kms_key_id = \ + "https://fakekeyvault.vault.azure.net/keys/fakekeyname/fakekeyversion/extra" namespace = AzureKeyVaultKmsKeyIdNamespace(azure_keyvault_kms_key_id=invalid_azure_keyvault_kms_key_id) err = '--azure-keyvault-kms-key-id is not a valid Key Vault key ID. ' \ 'See https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name' @@ -720,6 +738,31 @@ def test_valid_azure_keyvault_kms_key_vault_resource_id(self): validators.validate_azure_keyvault_kms_key_vault_resource_id(namespace) + def test_valid_azure_keyvault_kms_managed_hsm_resource_id(self): + valid_azure_keyvault_kms_key_vault_resource_id = "/subscriptions/8ecadfc9-d1a3-4ea4-b844-0d9f87e4d7c8/resourceGroups/foo/providers/Microsoft.KeyVault/managedHSMs/foo" + namespace = AzureKeyVaultKmsKeyVaultResourceIdNamespace(azure_keyvault_kms_key_vault_resource_id=valid_azure_keyvault_kms_key_vault_resource_id) + + validators.validate_azure_keyvault_kms_key_vault_resource_id(namespace) + + def test_invalid_azure_keyvault_kms_key_vault_resource_id_wrong_provider(self): + invalid_azure_keyvault_kms_key_vault_resource_id = "/subscriptions/8ecadfc9-d1a3-4ea4-b844-0d9f87e4d7c8/resourceGroups/foo/providers/Microsoft.Storage/storageAccounts/foo" + namespace = AzureKeyVaultKmsKeyVaultResourceIdNamespace(azure_keyvault_kms_key_vault_resource_id=invalid_azure_keyvault_kms_key_vault_resource_id) + err = '--azure-keyvault-kms-key-vault-resource-id must reference a Microsoft.KeyVault resource.' + + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_azure_keyvault_kms_key_vault_resource_id(namespace) + self.assertEqual(str(cm.exception), err) + + def test_empty_azure_keyvault_kms_key_vault_resource_id(self): + namespace = AzureKeyVaultKmsKeyVaultResourceIdNamespace(azure_keyvault_kms_key_vault_resource_id="") + + validators.validate_azure_keyvault_kms_key_vault_resource_id(namespace) + + def test_none_azure_keyvault_kms_key_vault_resource_id(self): + namespace = AzureKeyVaultKmsKeyVaultResourceIdNamespace(azure_keyvault_kms_key_vault_resource_id=None) + + validators.validate_azure_keyvault_kms_key_vault_resource_id(namespace) + class TestValidateNodepoolName(unittest.TestCase): def test_invalid_nodepool_name_too_long(self):