Skip to content

Commit 39aff0d

Browse files
committed
[KMS] Implement PMK-aware validation for CMK
1 parent ac57da8 commit 39aff0d

File tree

3 files changed

+507
-10
lines changed

3 files changed

+507
-10
lines changed

src/aks-preview/azext_aks_preview/_validators.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
RequiredArgumentMissingError)
3232
from azure.cli.core.commands.validators import validate_tag
3333
from azure.cli.core.util import CLIError
34-
from azure.mgmt.core.tools import is_valid_resource_id
34+
from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id
3535
from knack.log import get_logger
3636

3737
logger = get_logger(__name__)
@@ -701,26 +701,83 @@ def validate_crg_id(namespace):
701701
def validate_azure_keyvault_kms_key_id(namespace):
702702
key_id = namespace.azure_keyvault_kms_key_id
703703
if key_id:
704-
err_msg = (
705-
"--azure-keyvault-kms-key-id is not a valid Key Vault key ID. "
706-
"See https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name" # pylint: disable=line-too-long
704+
# Check if PMK (Platform-Managed Keys) is enabled
705+
is_pmk_enabled = (
706+
hasattr(namespace, 'kms_infrastructure_encryption') and
707+
namespace.kms_infrastructure_encryption == "Enabled"
707708
)
708709

709710
https_prefix = "https://"
710711
if not key_id.startswith(https_prefix):
712+
err_msg = (
713+
"--azure-keyvault-kms-key-id is not a valid Key Vault key ID. "
714+
"See https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name" # pylint: disable=line-too-long
715+
)
711716
raise InvalidArgumentValueError(err_msg)
712717

713718
segments = key_id[len(https_prefix):].split("/")
714-
if len(segments) != 4 or segments[1] != "keys":
715-
raise InvalidArgumentValueError(err_msg)
719+
720+
if is_pmk_enabled:
721+
# PMK enabled (K2P): Only accept versionless key ID (3 segments: vault.net/keys/key-name)
722+
if len(segments) != 3 or segments[1] != "keys":
723+
err_msg = (
724+
"--azure-keyvault-kms-key-id is not a valid versionless Key Vault key ID for PMK. "
725+
"Valid format is https://{key-vault-url}/keys/{key-name}. "
726+
"See https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name" # pylint: disable=line-too-long
727+
)
728+
raise InvalidArgumentValueError(err_msg)
729+
else:
730+
# PMK disabled (KMS v2): Accept versioned key ID (4 segments)
731+
if len(segments) != 4 or segments[1] != "keys":
732+
err_msg = (
733+
"--azure-keyvault-kms-key-id is not a valid Key Vault key ID. "
734+
"See https://docs.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#vault-name-and-object-name" # pylint: disable=line-too-long
735+
)
736+
raise InvalidArgumentValueError(err_msg)
716737

717738

718739
def validate_azure_keyvault_kms_key_vault_resource_id(namespace):
719740
key_vault_resource_id = namespace.azure_keyvault_kms_key_vault_resource_id
720741
if key_vault_resource_id is None or key_vault_resource_id == '':
721742
return
743+
744+
# Check if PMK (Platform-Managed Keys) is enabled
745+
is_pmk_enabled = (
746+
hasattr(namespace, 'kms_infrastructure_encryption') and
747+
namespace.kms_infrastructure_encryption == "Enabled"
748+
)
749+
722750
if not is_valid_resource_id(key_vault_resource_id):
723-
raise InvalidArgumentValueError("--azure-keyvault-kms-key-vault-resource-id is not a valid Azure resource ID.")
751+
raise InvalidArgumentValueError(
752+
"--azure-keyvault-kms-key-vault-resource-id is not a valid Azure resource ID."
753+
)
754+
755+
# Additional validation for PMK scenarios
756+
if is_pmk_enabled:
757+
# Parse the resource ID to validate it's a KeyVault resource
758+
try:
759+
parsed = parse_resource_id(key_vault_resource_id)
760+
provider = parsed.get('namespace', '').lower()
761+
resource_type = parsed.get('type', '').lower()
762+
763+
if provider != 'microsoft.keyvault':
764+
raise InvalidArgumentValueError(
765+
"--azure-keyvault-kms-key-vault-resource-id must reference a "
766+
"Microsoft.KeyVault resource for PMK."
767+
)
768+
769+
if resource_type not in ['vaults', 'managedhsms']:
770+
raise InvalidArgumentValueError(
771+
"--azure-keyvault-kms-key-vault-resource-id must reference a Key Vault "
772+
"(vaults) or Managed HSM (managedHSMs) for PMK."
773+
)
774+
except InvalidArgumentValueError:
775+
# Re-raise our validation errors
776+
raise
777+
except Exception as ex:
778+
raise InvalidArgumentValueError(
779+
f"--azure-keyvault-kms-key-vault-resource-id parsing failed: {str(ex)}"
780+
)
724781

725782

726783
def validate_bootstrap_container_registry_resource_id(namespace):

src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py

Lines changed: 285 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11946,7 +11946,7 @@ def test_aks_disable_azurekeyvaultkms(
1194611946
name_prefix="clitest",
1194711947
location="eastus2euap",
1194811948
)
11949-
def test_aks_create_with_kms_infrastructure_encryption(
11949+
def test_aks_create_with_kms_pmk(
1195011950
self, resource_group, resource_group_location
1195111951
):
1195211952
aks_name = self.create_random_name("cliakstest", 16)
@@ -11991,6 +11991,290 @@ def test_aks_create_with_kms_infrastructure_encryption(
1199111991
],
1199211992
)
1199311993

11994+
@live_only()
11995+
@AllowLargeResponse()
11996+
@AKSCustomResourceGroupPreparer(
11997+
random_name_length=17,
11998+
name_prefix="clitest",
11999+
location="eastus2euap",
12000+
)
12001+
def test_aks_create_with_kms_pmk_and_cmk(
12002+
self, resource_group, resource_group_location
12003+
):
12004+
"""Test PMK-enabled cluster creation with versionless key ID"""
12005+
aks_name = self.create_random_name("cliakstest", 16)
12006+
kv_name = self.create_random_name("cliakstestkv", 16)
12007+
identity_name = self.create_random_name("cliakstestidentity", 24)
12008+
k8s_version = self._get_version_in_range(location=resource_group_location, min_version="1.33.0", max_version="1.34.0")
12009+
self.kwargs.update(
12010+
{
12011+
"resource_group": resource_group,
12012+
"name": aks_name,
12013+
"kv_name": kv_name,
12014+
"identity_name": identity_name,
12015+
"ssh_key_value": self.generate_ssh_keys(),
12016+
"k8s_version": k8s_version,
12017+
}
12018+
)
12019+
12020+
# create user-assigned identity
12021+
identity_id = self._get_user_assigned_identity(resource_group)
12022+
identity_object_id = self._get_principal_id_of_user_assigned_identity(identity_id)
12023+
assert identity_id is not None
12024+
assert identity_object_id is not None
12025+
self.kwargs.update(
12026+
{
12027+
"identity_id": identity_id,
12028+
"identity_object_id": identity_object_id,
12029+
}
12030+
)
12031+
12032+
# create key vault and key
12033+
create_keyvault = (
12034+
"keyvault create --resource-group={resource_group} --name={kv_name} --enable-rbac-authorization=false --no-self-perms -o json"
12035+
)
12036+
self.cmd(
12037+
create_keyvault,
12038+
checks=[self.check("properties.provisioningState", "Succeeded")],
12039+
)
12040+
12041+
# set access policy for test identity
12042+
test_identity_object_id = self._get_test_identity_object_id()
12043+
test_identity_access_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \
12044+
'--key-permissions all --object-id ' + test_identity_object_id
12045+
self.cmd(test_identity_access_policy, checks=[
12046+
self.check('properties.provisioningState', 'Succeeded')
12047+
])
12048+
12049+
# create key and extract versionless key ID
12050+
create_key = "keyvault key create -n kms --vault-name {kv_name} -o json"
12051+
key = self.cmd(
12052+
create_key, checks=[self.check("attributes.enabled", True)]
12053+
).get_output_in_json()
12054+
key_id_versioned = key["key"]["kid"]
12055+
# Extract versionless key ID (remove version part)
12056+
# Format: https://{vault}.vault.azure.net/keys/{name}/{version}
12057+
# We want: https://{vault}.vault.azure.net/keys/{name}
12058+
key_id_parts = key_id_versioned.rsplit('/', 1)
12059+
key_id_versionless = key_id_parts[0]
12060+
12061+
assert key_id_versionless is not None
12062+
self.kwargs.update(
12063+
{
12064+
"key_id": key_id_versionless,
12065+
}
12066+
)
12067+
12068+
# Get key vault resource ID
12069+
kv_resource_id = self.cmd(
12070+
"keyvault show --resource-group={resource_group} --name={kv_name} --query id -o tsv"
12071+
).output.strip()
12072+
self.kwargs.update(
12073+
{
12074+
"kv_resource_id": kv_resource_id,
12075+
}
12076+
)
12077+
12078+
# assign access policy
12079+
set_policy = (
12080+
"keyvault set-policy --resource-group={resource_group} --name={kv_name} "
12081+
"--object-id {identity_object_id} --key-permissions encrypt decrypt -o json"
12082+
)
12083+
self.cmd(
12084+
set_policy, checks=[self.check("properties.provisioningState", "Succeeded")]
12085+
)
12086+
12087+
# create cluster with PMK enabled and versionless key ID
12088+
create_cmd = (
12089+
"aks create --resource-group={resource_group} --name={name} "
12090+
"--assign-identity {identity_id} "
12091+
"--enable-azure-keyvault-kms --azure-keyvault-kms-key-id={key_id} "
12092+
"--azure-keyvault-kms-key-vault-resource-id={kv_resource_id} "
12093+
"--kms-infrastructure-encryption=Enabled "
12094+
"--kubernetes-version={k8s_version} "
12095+
"--ssh-key-value={ssh_key_value} "
12096+
"--aks-custom-headers AKSHTTPCustomFeatures=Microsoft.ContainerService/KMSPMKPreview "
12097+
"-o json"
12098+
)
12099+
self.cmd(
12100+
create_cmd,
12101+
checks=[
12102+
self.check("provisioningState", "Succeeded"),
12103+
self.check("securityProfile.azureKeyVaultKms.enabled", True),
12104+
self.check("securityProfile.azureKeyVaultKms.keyId", key_id_versionless),
12105+
self.check("securityProfile.azureKeyVaultKms.keyVaultResourceId", kv_resource_id),
12106+
self.check(
12107+
"securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption",
12108+
"Enabled"
12109+
),
12110+
],
12111+
)
12112+
12113+
# delete
12114+
cmd = (
12115+
"aks delete --resource-group={resource_group} --name={name} --yes --no-wait"
12116+
)
12117+
self.cmd(
12118+
cmd,
12119+
checks=[
12120+
self.is_empty(),
12121+
],
12122+
)
12123+
12124+
@live_only()
12125+
@AllowLargeResponse()
12126+
@AKSCustomResourceGroupPreparer(
12127+
random_name_length=17,
12128+
name_prefix="clitest",
12129+
location="centraluseuap",
12130+
)
12131+
def test_aks_update_with_pmk_enabled_key_rotation(
12132+
self, resource_group, resource_group_location
12133+
):
12134+
"""Test PMK-enabled cluster key rotation with versionless key ID"""
12135+
aks_name = self.create_random_name("cliakstest", 16)
12136+
kv_name = self.create_random_name("cliakstestkv", 16)
12137+
identity_name = self.create_random_name("cliakstestidentity", 24)
12138+
k8s_version = self._get_version_in_range(location=resource_group_location, min_version="1.33.0", max_version="1.34.0")
12139+
self.kwargs.update(
12140+
{
12141+
"resource_group": resource_group,
12142+
"name": aks_name,
12143+
"kv_name": kv_name,
12144+
"identity_name": identity_name,
12145+
"ssh_key_value": self.generate_ssh_keys(),
12146+
"k8s_version": k8s_version,
12147+
}
12148+
)
12149+
12150+
# create user-assigned identity
12151+
identity_id = self._get_user_assigned_identity(resource_group)
12152+
identity_object_id = self._get_principal_id_of_user_assigned_identity(identity_id)
12153+
assert identity_id is not None
12154+
assert identity_object_id is not None
12155+
self.kwargs.update(
12156+
{
12157+
"identity_id": identity_id,
12158+
"identity_object_id": identity_object_id,
12159+
}
12160+
)
12161+
12162+
# create key vault and first key
12163+
create_keyvault = (
12164+
"keyvault create --resource-group={resource_group} --name={kv_name} --enable-rbac-authorization=false --no-self-perms -o json"
12165+
)
12166+
self.cmd(
12167+
create_keyvault,
12168+
checks=[self.check("properties.provisioningState", "Succeeded")],
12169+
)
12170+
12171+
# set access policy for test identity
12172+
test_identity_object_id = self._get_test_identity_object_id()
12173+
test_identity_access_policy = 'keyvault set-policy --resource-group={resource_group} --name={kv_name} ' \
12174+
'--key-permissions all --object-id ' + test_identity_object_id
12175+
self.cmd(test_identity_access_policy, checks=[
12176+
self.check('properties.provisioningState', 'Succeeded')
12177+
])
12178+
12179+
# create first key
12180+
create_key = "keyvault key create -n kms --vault-name {kv_name} -o json"
12181+
key = self.cmd(
12182+
create_key, checks=[self.check("attributes.enabled", True)]
12183+
).get_output_in_json()
12184+
key_id_versioned_0 = key["key"]["kid"]
12185+
key_id_versionless = key_id_versioned_0.rsplit('/', 1)[0]
12186+
12187+
assert key_id_versionless is not None
12188+
self.kwargs.update(
12189+
{
12190+
"key_id": key_id_versionless,
12191+
}
12192+
)
12193+
12194+
# Get key vault resource ID
12195+
kv_resource_id = self.cmd(
12196+
"keyvault show --resource-group={resource_group} --name={kv_name} --query id -o tsv"
12197+
).output.strip()
12198+
self.kwargs.update(
12199+
{
12200+
"kv_resource_id": kv_resource_id,
12201+
}
12202+
)
12203+
12204+
# assign access policy
12205+
set_policy = (
12206+
"keyvault set-policy --resource-group={resource_group} --name={kv_name} "
12207+
"--object-id {identity_object_id} --key-permissions encrypt decrypt -o json"
12208+
)
12209+
self.cmd(
12210+
set_policy, checks=[self.check("properties.provisioningState", "Succeeded")]
12211+
)
12212+
12213+
# create cluster with PMK enabled
12214+
create_cmd = (
12215+
"aks create --resource-group={resource_group} --name={name} "
12216+
"--assign-identity {identity_id} "
12217+
"--enable-azure-keyvault-kms --azure-keyvault-kms-key-id={key_id} "
12218+
"--azure-keyvault-kms-key-vault-network-access=Public "
12219+
"--azure-keyvault-kms-key-vault-resource-id={kv_resource_id} "
12220+
"--kms-infrastructure-encryption=Enabled "
12221+
"--kubernetes-version={k8s_version} "
12222+
"--ssh-key-value={ssh_key_value} "
12223+
"--aks-custom-headers AKSHTTPCustomFeatures=Microsoft.ContainerService/KMSPMKPreview "
12224+
"-o json"
12225+
)
12226+
self.cmd(
12227+
create_cmd,
12228+
checks=[
12229+
self.check("provisioningState", "Succeeded"),
12230+
self.check("securityProfile.azureKeyVaultKms.enabled", True),
12231+
self.check("securityProfile.azureKeyVaultKms.keyId", key_id_versionless),
12232+
self.check(
12233+
"securityProfile.kubernetesResourceObjectEncryptionProfile.infrastructureEncryption",
12234+
"Enabled"
12235+
),
12236+
],
12237+
)
12238+
12239+
# Create a new version of the same key (simulating key rotation)
12240+
# With versionless key ID, the cluster should automatically use the new version
12241+
key_new_version = self.cmd(
12242+
create_key, checks=[self.check("attributes.enabled", True)]
12243+
).get_output_in_json()
12244+
key_id_versioned_1 = key_new_version["key"]["kid"]
12245+
12246+
# The versionless key ID stays the same
12247+
assert key_id_versionless == key_id_versioned_1.rsplit('/', 1)[0]
12248+
12249+
# Update cluster - with PMK and versionless key, no key ID change is needed for rotation
12250+
# The cluster will automatically pick up the new key version
12251+
update_cmd = (
12252+
"aks update --resource-group={resource_group} --name={name} "
12253+
"--enable-azure-keyvault-kms --azure-keyvault-kms-key-id={key_id} "
12254+
"--azure-keyvault-kms-key-vault-network-access=Public "
12255+
"--aks-custom-headers AKSHTTPCustomFeatures=Microsoft.ContainerService/KMSPMKPreview "
12256+
"-o json"
12257+
)
12258+
self.cmd(
12259+
update_cmd,
12260+
checks=[
12261+
self.check("provisioningState", "Succeeded"),
12262+
self.check("securityProfile.azureKeyVaultKms.enabled", True),
12263+
self.check("securityProfile.azureKeyVaultKms.keyId", key_id_versionless),
12264+
],
12265+
)
12266+
12267+
# delete
12268+
cmd = (
12269+
"aks delete --resource-group={resource_group} --name={name} --yes --no-wait"
12270+
)
12271+
self.cmd(
12272+
cmd,
12273+
checks=[
12274+
self.is_empty(),
12275+
],
12276+
)
12277+
1199412278
@AllowLargeResponse()
1199512279
@AKSCustomResourceGroupPreparer(
1199612280
random_name_length=17,

0 commit comments

Comments
 (0)