diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py index 6d40452430a..b49662708da 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py @@ -260,6 +260,7 @@ def data_plane_azure_keyvault_ekm_client(cli_ctx, command_args): return KeyVaultEkmClient( vault_url=vault_url, credential=credential, + api_version='2026-07-01-preview', verify_challenge_resource=False, **client_kwargs) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_help.py b/src/azure-cli/azure/cli/command_modules/keyvault/_help.py index a6a6df2d03e..45aa38ac025 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_help.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_help.py @@ -983,11 +983,23 @@ helps['keyvault ekm-connection create'] = """ type: command short-summary: Create the EKM connection. +long-summary: In PrivateEndpoint mode, host is the name of an EKM private endpoint whose connection has been approved by the Private Link Service owner. The proxy CA certificates are required in both modes. +examples: + - name: Connect to an EKM proxy over the public network. + text: az keyvault ekm-connection create --hsm-name MyHSM --host proxy.example.com --path-prefix /api/v1 --server-ca-certificate proxy-ca.pem + - name: Connect to an approved EKM private endpoint. + text: az keyvault ekm-connection create --hsm-name MyHSM --host ekm-proxy-pe --connectivity-mode PrivateEndpoint --path-prefix /api/v1 --server-ca-certificate proxy-ca.pem --server-cn proxy.example.com """ helps['keyvault ekm-connection update'] = """ type: command short-summary: Update the EKM connection. +long-summary: Omitted fields retain their current values. Specify host when changing connectivity mode. +examples: + - name: Switch an EKM connection to an approved private endpoint. + text: az keyvault ekm-connection update --hsm-name MyHSM --connectivity-mode PrivateEndpoint --host ekm-proxy-pe + - name: Switch an EKM connection back to a public proxy. + text: az keyvault ekm-connection update --hsm-name MyHSM --connectivity-mode Public --host proxy.example.com:443 """ helps['keyvault ekm-connection show'] = """ @@ -1015,6 +1027,80 @@ short-summary: Show the EKM proxy client certificate. """ +helps['keyvault ekm-connection private-endpoint'] = """ +type: group +short-summary: Manage private endpoints from a Managed HSM to an EKM proxy. +long-summary: These are outbound EKM proxy endpoints, not inbound Managed HSM private endpoint connections. A Managed HSM supports up to two EKM private endpoints. +""" + +helps['keyvault ekm-connection private-endpoint create'] = """ +type: command +short-summary: Create an EKM proxy private endpoint. +long-summary: Returns the completed operation record unless no-wait is specified. The Private Link Service owner must approve the connection before it can be used by an EKM connection. +examples: + - name: Create an endpoint using a Private Link Service alias. + text: az keyvault ekm-connection private-endpoint create --hsm-name MyHSM --name ekm-proxy-pe --private-link-service MyService.Alias + - name: Request an endpoint without waiting for provisioning. + text: az keyvault ekm-connection private-endpoint create --id https://MyHSM.managedhsm.azure.net --name ekm-proxy-pe --private-link-service MyService.Alias --request-message "Please approve this connection" --no-wait +""" + +helps['keyvault ekm-connection private-endpoint delete'] = """ +type: command +short-summary: Delete an EKM proxy private endpoint. +long-summary: Deletion is rejected while an EKM connection references the endpoint. Update or remove that connection first. Returns the completed operation record unless no-wait is specified. +examples: + - name: Delete an unused EKM private endpoint. + text: az keyvault ekm-connection private-endpoint delete --hsm-name MyHSM --name ekm-proxy-pe + - name: Start deletion without prompting or waiting. + text: az keyvault ekm-connection private-endpoint delete --id https://MyHSM.managedhsm.azure.net --name ekm-proxy-pe --yes --no-wait +""" + +helps['keyvault ekm-connection private-endpoint show'] = """ +type: command +short-summary: Show an EKM private endpoint and its connection approval status. +examples: + - name: Show an EKM private endpoint. + text: az keyvault ekm-connection private-endpoint show --hsm-name MyHSM --name ekm-proxy-pe + - name: Read the Private Link Service approval status. + text: az keyvault ekm-connection private-endpoint show --id https://MyHSM.managedhsm.azure.net --name ekm-proxy-pe --query privateLinkServiceConnectionState.status -o tsv +""" + +helps['keyvault ekm-connection private-endpoint list'] = """ +type: command +short-summary: List EKM private endpoints on a Managed HSM. +examples: + - name: List all EKM private endpoints. + text: az keyvault ekm-connection private-endpoint list --hsm-name MyHSM + - name: List the names of approved endpoints. + text: az keyvault ekm-connection private-endpoint list --id https://MyHSM.managedhsm.azure.net --query "[?privateLinkServiceConnectionState.status=='Approved'].name" -o tsv +""" + +helps['keyvault ekm-connection private-endpoint wait'] = """ +type: command +short-summary: Wait for an EKM private endpoint to reach a condition. +long-summary: The created condition waits for provisioning to succeed, not for Private Link Service approval. Use a custom condition to wait for approval. +examples: + - name: Wait for endpoint provisioning to complete. + text: az keyvault ekm-connection private-endpoint wait --hsm-name MyHSM --name ekm-proxy-pe --created + - name: Wait for the Private Link Service owner to approve the connection. + text: az keyvault ekm-connection private-endpoint wait --hsm-name MyHSM --name ekm-proxy-pe --custom "privateLinkServiceConnectionState.status=='Approved'" --interval 10 --timeout 600 +""" + +helps['keyvault ekm-connection private-endpoint operation'] = """ +type: group +short-summary: Inspect EKM private endpoint operations. +""" + +helps['keyvault ekm-connection private-endpoint operation show'] = """ +type: command +short-summary: Show the status of an EKM private endpoint create or delete operation. +examples: + - name: Show a private endpoint operation. + text: az keyvault ekm-connection private-endpoint operation show --hsm-name MyHSM --job-id 00000000-0000-0000-0000-000000000000 + - name: Read the status of an operation without waiting for it to complete. + text: az keyvault ekm-connection private-endpoint operation show --id https://MyHSM.managedhsm.azure.net --job-id 00000000-0000-0000-0000-000000000000 --query status -o tsv +""" + helps['keyvault security-domain'] = """ type: group short-summary: Manage security domain operations. diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_params.py b/src/azure-cli/azure/cli/command_modules/keyvault/_params.py index 20ed41fdc0e..c7e8327ed5c 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_params.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_params.py @@ -630,7 +630,10 @@ class CLISecurityDomainOperation(str, Enum): with self.argument_context('keyvault ekm-connection create', arg_group='EKM Connection') as c: c.argument('host', options_list=['--host'], required=True, - help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.') + help='EKM proxy host (FQDN or FQDN:port), or the private endpoint name in PrivateEndpoint mode. ' + 'For Public mode, port 443 is assumed if omitted.') + c.argument('connectivity_mode', arg_type=get_enum_type(['Public', 'PrivateEndpoint']), is_preview=True, + help='Connectivity mode for the EKM proxy. The service defaults to Public when omitted.') c.extra('path_prefix', options_list=['--path-prefix'], help='Optional path prefix to append to EKM proxy requests. Must start with "/".') c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type, @@ -643,7 +646,10 @@ class CLISecurityDomainOperation(str, Enum): with self.argument_context('keyvault ekm-connection update', arg_group='EKM Connection') as c: c.argument('host', options_list=['--host'], required=False, - help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.') + help='EKM proxy host (FQDN or FQDN:port), or the private endpoint name in PrivateEndpoint mode. ' + 'Required when changing connectivity mode.') + c.argument('connectivity_mode', arg_type=get_enum_type(['Public', 'PrivateEndpoint']), is_preview=True, + help='Connectivity mode for the EKM proxy. Preserves the existing mode when omitted.') c.extra('path_prefix', options_list=['--path-prefix'], help='Optional path prefix to append to EKM proxy requests. Must start with "/".') c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type, @@ -660,6 +666,28 @@ class CLISecurityDomainOperation(str, Enum): c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm, help='Full URI of the HSM.') c.ignore('vault_base_url') + + for scope in ['create', 'delete', 'show', 'list', 'wait', 'operation show']: + with self.argument_context('keyvault ekm-connection private-endpoint {}'.format(scope), + arg_group='HSM Id') as c: + c.extra('hsm_name', hsm_url_type, required=False, + help='Name of the HSM. Can be omitted if --id is specified.') + c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm, + help='Full URI of the HSM.') + c.ignore('vault_base_url') + + with self.argument_context('keyvault ekm-connection private-endpoint') as c: + c.argument('private_endpoint_name', options_list=['--name', '-n'], + help='EKM private endpoint name. Use 1-24 letters, digits or hyphens, ' + 'starting and ending with a letter or digit.') + + with self.argument_context('keyvault ekm-connection private-endpoint create') as c: + c.argument('private_link_service_id', options_list=['--private-link-service'], + help='Alias of the Private Link Service that fronts the EKM proxy.') + c.argument('request_message', help='Message for the Private Link Service owner when approving the connection.') + + with self.argument_context('keyvault ekm-connection private-endpoint operation show') as c: + c.argument('job_id', help='Job ID returned by a private endpoint create or delete operation.') # endregion # region keyvault backup/restore diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py b/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py index 668b83ae9a0..1e0fbb8a518 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py @@ -807,6 +807,21 @@ def _normalize_ekm_host(host: str): return f'{hostname}:{port}' +def _validate_ekm_private_endpoint_name(name): + if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,22}[A-Za-z0-9])?', name): + raise InvalidArgumentValueError('The EKM private endpoint name must be 1-24 letters, digits or hyphens, ' + 'and start and end with a letter or digit.') + return name + + +def _normalize_ekm_connection_host(host, connectivity_mode=None): + if connectivity_mode == 'PrivateEndpoint': + return _validate_ekm_private_endpoint_name(host) + if connectivity_mode not in (None, 'Public'): + raise InvalidArgumentValueError('--connectivity-mode must be Public or PrivateEndpoint.') + return _normalize_ekm_host(host) + + def _flatten_list(value): if value is None: return None @@ -872,7 +887,7 @@ def validate_ekm_connection_base(cmd, ns): # pylint: disable=unused-argument def validate_ekm_connection_create(cmd, ns): validate_ekm_connection_base(cmd, ns) - ns.host = _normalize_ekm_host(ns.host) + ns.host = _normalize_ekm_connection_host(ns.host, getattr(ns, 'connectivity_mode', None)) _validate_ekm_path_prefix(getattr(ns, 'path_prefix', None)) server_ca_certificates = _load_certificates_as_der_bytes(getattr(ns, 'server_ca_certificates', None)) if not server_ca_certificates: @@ -882,13 +897,19 @@ def validate_ekm_connection_create(cmd, ns): def validate_ekm_connection_update(cmd, ns): validate_ekm_connection_base(cmd, ns) - if getattr(ns, 'host', None): - ns.host = _normalize_ekm_host(ns.host) + if getattr(ns, 'host', None) is not None and getattr(ns, 'connectivity_mode', None) is not None: + ns.host = _normalize_ekm_connection_host(ns.host, ns.connectivity_mode) _validate_ekm_path_prefix(getattr(ns, 'path_prefix', None)) if getattr(ns, 'server_ca_certificates', None): ns.server_ca_certificates = _load_certificates_as_der_bytes(ns.server_ca_certificates) +def validate_ekm_private_endpoint(cmd, ns): + validate_ekm_connection_base(cmd, ns) + if getattr(ns, 'private_endpoint_name', None) is not None: + _validate_ekm_private_endpoint_name(ns.private_endpoint_name) + + # pylint: disable=line-too-long, too-many-locals def process_certificate_policy(cmd, ns): policy = getattr(ns, 'policy', None) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/commands.py b/src/azure-cli/azure/cli/command_modules/keyvault/commands.py index 5db91e6d7c5..671450ee7c6 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/commands.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/commands.py @@ -27,7 +27,8 @@ from azure.cli.command_modules.keyvault._validators import ( process_secret_set_namespace, validate_key_create, validate_private_endpoint_connection_id, validate_role_assignment_args, - validate_ekm_connection_base, validate_ekm_connection_create, validate_ekm_connection_update) + validate_ekm_connection_base, validate_ekm_connection_create, validate_ekm_connection_update, + validate_ekm_private_endpoint) def transform_assignment_list(result): @@ -153,6 +154,21 @@ def load_command_table(self, _): with self.command_group('keyvault ekm-connection certificate', command_type=data_ekm_custom, is_preview=True) as g: g.keyvault_custom('show', 'get_ekm_certificate', validator=validate_ekm_connection_base) + with self.command_group('keyvault ekm-connection private-endpoint', command_type=data_ekm_custom, + is_preview=True) as g: + g.keyvault_custom('create', 'create_ekm_private_endpoint', validator=validate_ekm_private_endpoint, + supports_no_wait=True) + g.keyvault_custom('delete', 'delete_ekm_private_endpoint', validator=validate_ekm_private_endpoint, + supports_no_wait=True, confirmation=True) + g.keyvault_custom('show', 'get_ekm_private_endpoint', validator=validate_ekm_private_endpoint) + g.keyvault_custom('list', 'list_ekm_private_endpoints', validator=validate_ekm_connection_base) + g.custom_wait_command('wait', 'get_ekm_private_endpoint', validator=validate_ekm_private_endpoint, + custom_command_type=data_ekm_custom) + + with self.command_group('keyvault ekm-connection private-endpoint operation', command_type=data_ekm_custom, + is_preview=True) as g: + g.keyvault_custom('show', 'get_ekm_private_endpoint_operation', validator=validate_ekm_connection_base) + with self.command_group('keyvault key', data_key_entity.command_type) as g: g.keyvault_custom('create', 'create_key', transform=transform_key_output, validator=validate_key_create) g.keyvault_command('set-attributes', 'update_key_properties', transform=transform_key_output) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/custom.py b/src/azure-cli/azure/cli/command_modules/keyvault/custom.py index 93a2024411f..82911286001 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/custom.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/custom.py @@ -1254,23 +1254,32 @@ def delete_ekm_connection(client): return client.delete_ekm_connection() -def create_ekm_connection(client, host, path_prefix=None, server_ca_certificates=None, server_subject_common_name=None): +def create_ekm_connection(client, host, path_prefix=None, server_ca_certificates=None, server_subject_common_name=None, + connectivity_mode=None): from azure.keyvault.administration import KeyVaultEkmConnection ekm_connection = KeyVaultEkmConnection( host=host, path_prefix=path_prefix, server_ca_certificates=server_ca_certificates, - server_subject_common_name=server_subject_common_name + server_subject_common_name=server_subject_common_name, + connectivity_mode=connectivity_mode ) return client.create_ekm_connection(ekm_connection) def update_ekm_connection(client, host=None, path_prefix=None, server_ca_certificates=None, - server_subject_common_name=None): + server_subject_common_name=None, connectivity_mode=None): + from azure.cli.command_modules.keyvault._validators import _normalize_ekm_connection_host + existing = client.get_ekm_connection() + existing_mode = getattr(existing, 'connectivity_mode', None) or 'Public' + if connectivity_mode is not None and connectivity_mode != existing_mode and host is None: + raise RequiredArgumentMissingError('--host is required when changing --connectivity-mode.') if host is not None: - existing.host = host + existing.host = _normalize_ekm_connection_host(host, connectivity_mode or existing_mode) + if connectivity_mode is not None: + existing.connectivity_mode = connectivity_mode if path_prefix is not None: existing.path_prefix = path_prefix if server_ca_certificates is not None: @@ -1278,6 +1287,37 @@ def update_ekm_connection(client, host=None, path_prefix=None, server_ca_certifi if server_subject_common_name is not None: existing.server_subject_common_name = server_subject_common_name return client.update_ekm_connection(existing) + + +def create_ekm_private_endpoint(client, private_endpoint_name, private_link_service_id, request_message=None, + no_wait=False): + from azure.mgmt.core.polling.arm_polling import ARMPolling + + polling = False if no_wait else ARMPolling(lro_options={'final-state-via': 'azure-async-operation'}) + poller = client.begin_create_ekm_private_endpoint( + name=private_endpoint_name, private_link_service_id=private_link_service_id, + request_message=request_message, polling=polling) + return None if no_wait else poller + + +def delete_ekm_private_endpoint(client, private_endpoint_name, no_wait=False): + from azure.mgmt.core.polling.arm_polling import ARMPolling + + polling = False if no_wait else ARMPolling(lro_options={'final-state-via': 'azure-async-operation'}) + poller = client.begin_delete_ekm_private_endpoint(name=private_endpoint_name, polling=polling) + return None if no_wait else poller + + +def get_ekm_private_endpoint(client, private_endpoint_name): + return client.get_ekm_private_endpoint(name=private_endpoint_name) + + +def list_ekm_private_endpoints(client): + return list(client.list_ekm_private_endpoints()) + + +def get_ekm_private_endpoint_operation(client, job_id): + return client.get_ekm_private_endpoint_operation_status(job_id=job_id) # endregion diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py b/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py index c96ebf92ef5..ac29df63080 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py @@ -286,6 +286,305 @@ def test_validate_ekm_connection_create_requires_certificate(self): validate_ekm_connection_create(None, ns) +class KeyVaultEkmPrivateEndpointUnitTest(unittest.TestCase): + def test_keyvault_ekm_private_endpoint_create_polling(self): + from azure.cli.command_modules.keyvault.custom import create_ekm_private_endpoint + from azure.mgmt.core.polling.arm_polling import ARMPolling + + for no_wait in [False, True]: + with self.subTest(no_wait=no_wait): + client = mock.Mock() + poller = client.begin_create_ekm_private_endpoint.return_value + result = create_ekm_private_endpoint(client, 'pe-one', 'service-alias', 'Approve this', no_wait) + client.begin_create_ekm_private_endpoint.assert_called_once_with( + name='pe-one', private_link_service_id='service-alias', + request_message='Approve this', polling=False if no_wait else mock.ANY) + self.assertIs(result, None if no_wait else poller) + poller.result.assert_not_called() + if not no_wait: + self.assertIsInstance(client.begin_create_ekm_private_endpoint.call_args.kwargs['polling'], ARMPolling) + + def test_keyvault_ekm_private_endpoint_delete_polling(self): + from azure.cli.command_modules.keyvault.custom import delete_ekm_private_endpoint + + for no_wait in [False, True]: + with self.subTest(no_wait=no_wait): + client = mock.Mock() + poller = client.begin_delete_ekm_private_endpoint.return_value + result = delete_ekm_private_endpoint(client, 'pe-one', no_wait) + client.begin_delete_ekm_private_endpoint.assert_called_once_with( + name='pe-one', polling=False if no_wait else mock.ANY) + self.assertIs(result, None if no_wait else poller) + client.delete_ekm_connection.assert_not_called() + + def test_keyvault_ekm_private_endpoint_reads(self): + from azure.cli.command_modules.keyvault.custom import ( + get_ekm_private_endpoint, list_ekm_private_endpoints, get_ekm_private_endpoint_operation) + + client = mock.Mock() + self.assertIs(get_ekm_private_endpoint(client, 'pe-one'), client.get_ekm_private_endpoint.return_value) + client.get_ekm_private_endpoint.assert_called_once_with(name='pe-one') + endpoint = argparse.Namespace(name='pe-one', provisioning_state='Succeeded') + client.list_ekm_private_endpoints.return_value = iter([endpoint]) + self.assertEqual(list_ekm_private_endpoints(client), [endpoint]) + client.list_ekm_private_endpoints.return_value = iter([]) + self.assertEqual(list_ekm_private_endpoints(client), []) + result = get_ekm_private_endpoint_operation(client, 'operation-id') + self.assertIs(result, client.get_ekm_private_endpoint_operation_status.return_value) + client.get_ekm_private_endpoint_operation_status.assert_called_once_with(job_id='operation-id') + + def test_keyvault_ekm_private_host_validation(self): + from azure.cli.command_modules.keyvault._validators import _normalize_ekm_connection_host + from azure.cli.core.azclierror import InvalidArgumentValueError + + self.assertEqual(_normalize_ekm_connection_host('proxy.example.com'), 'proxy.example.com:443') + self.assertEqual(_normalize_ekm_connection_host('proxy.example.com:8443', 'Public'), + 'proxy.example.com:8443') + for name in ['a', 'pe-one', 'a' * 24]: + self.assertEqual(_normalize_ekm_connection_host(name, 'PrivateEndpoint'), name) + for name in ['', 'a' * 25, '-pe', 'pe-', 'pe.one', 'pe_one', 'pe:443', 'pe/one', 'pe\n']: + with self.subTest(name=name), self.assertRaises(InvalidArgumentValueError): + _normalize_ekm_connection_host(name, 'PrivateEndpoint') + with self.assertRaises(InvalidArgumentValueError): + _normalize_ekm_connection_host('pe-one', 'Unknown') + + def test_keyvault_ekm_private_create_validation(self): + from azure.cli.command_modules.keyvault._validators import validate_ekm_connection_create + + namespace = argparse.Namespace( + hsm_name=None, identifier='https://example.managedhsm.azure.net', + host='pe-one', connectivity_mode='PrivateEndpoint', path_prefix='/api/v1', + server_ca_certificates=[os.path.join(CERTS_DIR, 'cert_0.cer')]) + with mock.patch('azure.cli.command_modules.keyvault._validators.set_vault_base_url'): + validate_ekm_connection_create(None, namespace) + self.assertEqual(namespace.host, 'pe-one') + self.assertIsInstance(namespace.server_ca_certificates[0], bytes) + + def test_keyvault_ekm_update_preserves_existing_mode(self): + from azure.cli.command_modules.keyvault._validators import validate_ekm_connection_update + from azure.cli.command_modules.keyvault.custom import update_ekm_connection + + for mode, expected_host in [('PrivateEndpoint', 'pe-two'), ('Public', 'pe-two:443'), + (None, 'pe-two:443')]: + with self.subTest(mode=mode): + existing = argparse.Namespace(host='pe-one', connectivity_mode=mode, + path_prefix='/api/v1', server_ca_certificates=[b'cert'], + server_subject_common_name='proxy') + client = mock.Mock() + client.get_ekm_connection.return_value = existing + namespace = argparse.Namespace(hsm_name=None, identifier='https://example.managedhsm.azure.net', + host='pe-two', connectivity_mode=None) + with mock.patch('azure.cli.command_modules.keyvault._validators.set_vault_base_url'): + validate_ekm_connection_update(None, namespace) + self.assertEqual(namespace.host, 'pe-two') + update_ekm_connection(client, host=namespace.host) + self.assertEqual(existing.host, expected_host) + self.assertEqual(existing.connectivity_mode, mode) + self.assertEqual(existing.path_prefix, '/api/v1') + self.assertEqual(existing.server_ca_certificates, [b'cert']) + client.update_ekm_connection.assert_called_once_with(existing) + + def test_keyvault_ekm_mode_switch_requires_host(self): + from azure.cli.command_modules.keyvault.custom import update_ekm_connection + from azure.cli.core.azclierror import RequiredArgumentMissingError + + for previous, target in [('Public', 'PrivateEndpoint'), ('PrivateEndpoint', 'Public')]: + client = mock.Mock() + client.get_ekm_connection.return_value = argparse.Namespace(host='pe-one', connectivity_mode=previous) + with self.subTest(previous=previous), self.assertRaises(RequiredArgumentMissingError): + update_ekm_connection(client, connectivity_mode=target) + client.update_ekm_connection.assert_not_called() + + def test_keyvault_ekm_mode_switch_with_host(self): + from azure.cli.command_modules.keyvault.custom import update_ekm_connection + + client = mock.Mock() + existing = argparse.Namespace(host='proxy.example.com:443', connectivity_mode='Public') + client.get_ekm_connection.return_value = existing + update_ekm_connection(client, host='pe-one', connectivity_mode='PrivateEndpoint') + self.assertEqual((existing.host, existing.connectivity_mode), ('pe-one', 'PrivateEndpoint')) + update_ekm_connection(client, host='proxy.example.com', connectivity_mode='Public') + self.assertEqual((existing.host, existing.connectivity_mode), ('proxy.example.com:443', 'Public')) + + def test_keyvault_ekm_private_update_rejects_host_port(self): + from azure.cli.command_modules.keyvault.custom import update_ekm_connection + from azure.cli.core.azclierror import InvalidArgumentValueError + + client = mock.Mock() + client.get_ekm_connection.return_value = argparse.Namespace(host='pe-one', connectivity_mode='PrivateEndpoint') + with self.assertRaises(InvalidArgumentValueError): + update_ekm_connection(client, host='pe-two:443') + client.update_ekm_connection.assert_not_called() + + +class KeyVaultEkmPrivateEndpointCommandTest(unittest.TestCase): + def setUp(self): + from pathlib import Path + from azure.core.credentials import AccessToken + + self.directory = tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parents[7]) + self.addCleanup(self.directory.cleanup) + environment = mock.patch.dict(os.environ, { + 'AZURE_CONFIG_DIR': self.directory.name, + 'AZURE_CORE_COLLECT_TELEMETRY': 'no', + 'AZURE_EXTENSION_USE_DYNAMIC_INSTALL': 'no', + 'AZURE_CORE_NO_COLOR': 'true' + }) + environment.start() + self.addCleanup(environment.stop) + self.host = 'https://ekm-unit.managedhsm.azure.net' + self.operation_id = '11111111-1111-1111-1111-111111111111' + self.requests = [] + self.endpoint = { + 'name': 'pe-one', 'location': 'centraluseuap', 'provisioningState': 'Succeeded', + 'properties': {'privateLinkServiceId': 'service-alias'}, + 'privateLinkServiceConnectionState': {'status': 'Approved'} + } + self.connection = {'host': 'proxy.example.com:443', 'path_prefix': '/api/v1', + 'server_ca_certificates': ['Y2VydA=='], 'connectivity_mode': 'Public'} + self.operation = {'jobId': self.operation_id, 'privateEndpointName': 'pe-one', + 'operationType': 'Create', 'status': 'Succeeded'} + self.deleted = False + credential = mock.Mock(spec=['get_token']) + credential.get_token.return_value = AccessToken('unit-test-token', int(time.time()) + 3600) + for patcher in [ + mock.patch('azure.cli.command_modules.keyvault._client_factory._prepare_data_plane_azure_keyvault_client', + return_value=(self.host, credential, '2026-07-01-preview')), + mock.patch('azure.cli.command_modules.keyvault._client_factory.prepare_client_kwargs_track2', + side_effect=lambda *args, **kwargs: {'http_logging_policy': None, 'polling_interval': 0}), + mock.patch('requests.sessions.Session.send', side_effect=self._respond) + ]: + patcher.start() + self.addCleanup(patcher.stop) + + def _respond(self, request, **kwargs): + from io import BytesIO + from urllib.parse import urlsplit, parse_qs + from requests import Response + from urllib3.response import HTTPResponse + + address = urlsplit(request.url) + self.assertEqual(address.hostname, 'ekm-unit.managedhsm.azure.net') + self.assertEqual(parse_qs(address.query).get('api-version'), ['2026-07-01-preview']) + body = json.loads(request.body) if request.body else None + if request.headers.get('Authorization'): + self.requests.append((request.method, address.path, body)) + response = Response() + response.request = request + response.url = request.url + response.headers['Content-Type'] = 'application/json' + response.status_code = 200 + if not request.headers.get('Authorization'): + response.status_code = 401 + response.headers['WWW-Authenticate'] = ( + 'Bearer authorization="https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000", ' + 'resource="https://managedhsm.azure.net"') + payload = {'error': {'code': 'Unauthorized', 'message': 'Authentication required.'}} + elif address.path == '/ekm/privateendpoint' and request.method == 'GET': + payload = {'value': [] if self.deleted else [self.endpoint]} + elif address.path == '/ekm/privateendpoint/operations/' + self.operation_id: + self.assertEqual(request.method, 'GET') + payload = self.operation + elif address.path == '/ekm/privateendpoint/pe-one': + if request.method in ['POST', 'DELETE']: + self.operation['operationType'] = 'Create' if request.method == 'POST' else 'Delete' + self.deleted = request.method == 'DELETE' + payload = dict(self.operation, status='Running') + response.status_code = 202 + response.headers['Azure-AsyncOperation'] = ( + self.host + '/ekm/privateendpoint/operations/' + self.operation_id + '?api-version=2026-07-01-preview') + response.headers['Retry-After'] = '0' + elif request.method == 'GET' and not self.deleted: + payload = self.endpoint + else: + self.assertEqual(request.method, 'GET') + response.status_code = 404 + payload = {'error': {'code': 'NotFound', 'message': 'Endpoint not found.'}} + elif address.path in ['/ekm', '/ekm/create']: + if request.method in ['POST', 'PATCH']: + self.connection.update(body) + else: + self.assertEqual(request.method, 'GET') + payload = self.connection + else: + self.fail('Unexpected SDK request: {} {}'.format(request.method, address.path)) + response._content = json.dumps(payload).encode('utf-8') + response._content_consumed = True + response.raw = HTTPResponse(body=BytesIO(response._content), status=response.status_code, + headers=dict(response.headers), preload_content=False) + return response + + def _invoke(self, arguments, expected_code=0): + from contextlib import redirect_stdout, redirect_stderr + from io import StringIO + from azure.cli.core import get_default_cli + + output, errors = StringIO(), StringIO() + with redirect_stdout(output), redirect_stderr(errors): + cli = get_default_cli() + cli.out_file = output + cli.config.set_value('core', 'collect_telemetry', 'no') + try: + code = cli.invoke(['keyvault', 'ekm-connection'] + arguments + + ['--id', self.host, '--output', 'json', '--only-show-errors']) + except SystemExit as result: + code = result.code + self.assertEqual(code, expected_code, errors.getvalue() + repr([ + (method, path) for method, path, _ in self.requests])) + return json.loads(output.getvalue()) if output.getvalue().strip() else None + + def test_keyvault_ekm_private_endpoint_cli_lifecycle(self): + created = self._invoke(['private-endpoint', 'create', '--name', 'pe-one', + '--private-link-service', 'service-alias', '--request-message', 'Please approve']) + self.assertEqual(created['status'], 'Succeeded') + self.assertEqual(self.requests[0], ('POST', '/ekm/privateendpoint/pe-one', + {'privateLinkServiceId': 'service-alias', 'requestMessage': 'Please approve'})) + endpoint = self._invoke(['private-endpoint', 'show', '--name', 'pe-one']) + self.assertEqual(endpoint['privateLinkServiceConnectionState']['status'], 'Approved') + self.assertEqual(self._invoke(['private-endpoint', 'list'])[0]['name'], 'pe-one') + self._invoke(['private-endpoint', 'wait', '--name', 'pe-one', '--created', '--timeout', '1']) + self._invoke(['private-endpoint', 'wait', '--name', 'pe-one', '--timeout', '1', + '--custom', "privateLinkServiceConnectionState.status=='Approved'"]) + operation = self._invoke(['private-endpoint', 'operation', 'show', '--job-id', self.operation_id]) + self.assertEqual(operation['jobId'], self.operation_id) + deleted = self._invoke(['private-endpoint', 'delete', '--name', 'pe-one', '--yes']) + self.assertEqual(deleted['status'], 'Succeeded') + self._invoke(['private-endpoint', 'wait', '--name', 'pe-one', '--deleted', '--timeout', '1']) + self.assertEqual(self._invoke(['private-endpoint', 'list']), []) + + def test_keyvault_ekm_private_endpoint_cli_no_wait(self): + created = self._invoke(['private-endpoint', 'create', '--name', 'pe-one', + '--private-link-service', 'service-alias', '--no-wait']) + self.assertIsNone(created) + self.assertEqual(len(self.requests), 1) + deleted = self._invoke(['private-endpoint', 'delete', '--name', 'pe-one', '--yes', '--no-wait']) + self.assertIsNone(deleted) + self.assertEqual([request[0] for request in self.requests], ['POST', 'DELETE']) + + def test_keyvault_ekm_connection_cli_private_mode(self): + created = self._invoke(['create', '--host', 'pe-one', '--connectivity-mode', 'PrivateEndpoint', + '--path-prefix', '/api/v1', '--server-ca-certificate', + os.path.join(CERTS_DIR, 'cert_0.cer')]) + self.assertEqual((created['host'], created['connectivityMode']), ('pe-one', 'PrivateEndpoint')) + updated = self._invoke(['update', '--host', 'pe-two']) + self.assertEqual((updated['host'], updated['connectivityMode']), ('pe-two', 'PrivateEndpoint')) + updated = self._invoke(['update', '--path-prefix', '/api/v2']) + self.assertEqual((updated['host'], updated['connectivityMode']), ('pe-two', 'PrivateEndpoint')) + updated = self._invoke(['update', '--connectivity-mode', 'Public', '--host', 'proxy.example.com']) + self.assertEqual((updated['host'], updated['connectivityMode']), ('proxy.example.com:443', 'Public')) + + def test_keyvault_ekm_connection_cli_public_mode(self): + created = self._invoke(['create', '--host', 'proxy.example.com', '--server-ca-certificate', + os.path.join(CERTS_DIR, 'cert_0.cer')]) + self.assertEqual(created['host'], 'proxy.example.com:443') + self.assertNotIn('connectivity_mode', self.requests[0][2]) + + def test_keyvault_ekm_private_endpoint_cli_failure(self): + self.operation.update(status='Failed', error={'code': 'ProvisioningFailed', 'message': 'Cannot provision.'}) + self._invoke(['private-endpoint', 'create', '--name', 'pe-one', + '--private-link-service', 'service-alias'], expected_code=1) + + class KeyVaultEkmCertificateSerializationUnitTest(unittest.TestCase): def test_get_ekm_certificate_serializes_der_bytes(self): from azure.cli.command_modules.keyvault._validators import _load_certificates_as_der_bytes diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 43a9907deda..2c31414967e 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -12,7 +12,7 @@ azure-core==1.39.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.8.0b2 +azure-keyvault-administration==4.8.0b3 azure-keyvault-certificates==4.7.0 azure-keyvault-keys==4.12.0b2 azure-keyvault-secrets==4.7.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index d4e16253639..9904172aea9 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -12,7 +12,7 @@ azure-core==1.39.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.8.0b2 +azure-keyvault-administration==4.8.0b3 azure-keyvault-certificates==4.7.0 azure-keyvault-keys==4.12.0b2 azure-keyvault-secrets==4.7.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 9c82e9488d2..03678c5c16d 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -12,7 +12,7 @@ azure-core==1.39.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.8.0b2 +azure-keyvault-administration==4.8.0b3 azure-keyvault-certificates==4.7.0 azure-keyvault-keys==4.12.0b2 azure-keyvault-secrets==4.7.0 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index dc0ad9e344b..a8d23d2b38f 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -59,7 +59,7 @@ 'azure-cosmos~=3.0,>=3.0.2', 'azure-data-tables==12.4.0', 'azure-datalake-store~=1.0.1', - 'azure-keyvault-administration==4.8.0b2', + 'azure-keyvault-administration==4.8.0b3', 'azure-keyvault-certificates==4.7.0', 'azure-keyvault-keys==4.12.0b2', 'azure-keyvault-secrets==4.7.0',