diff --git a/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md b/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md index 936c7986077d..6590cf5bd99f 100644 --- a/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md @@ -1,5 +1,14 @@ # Release History +## 4.11.1 (2026-04-29) + +### Bugs Fixed + +- Fixed `CertificateClient.begin_create_certificate` (and its async counterpart) incorrectly raising + `ValueError` when a `CertificatePolicy` was created with only `san_ip_addresses` or `san_uris` and no + `subject`, `san_dns_names`, `san_emails`, or `san_user_principal_names`. IP addresses and URIs are + valid subject alternative name types and are now recognized by the client's policy validator. + ## 4.11.0 (2026-03-27) ### Features Added diff --git a/sdk/keyvault/azure-keyvault-certificates/assets.json b/sdk/keyvault/azure-keyvault-certificates/assets.json index c49a6542155e..9c1a25dfdb79 100644 --- a/sdk/keyvault/azure-keyvault-certificates/assets.json +++ b/sdk/keyvault/azure-keyvault-certificates/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-certificates", - "Tag": "python/keyvault/azure-keyvault-certificates_6bfbc7f623" + "Tag": "python/keyvault/azure-keyvault-certificates_a5c5c34814" } diff --git a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_client.py b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_client.py index d0f4f65fea02..6ce432f1874d 100644 --- a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_client.py +++ b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_client.py @@ -100,7 +100,14 @@ def begin_create_certificate( :caption: Create a certificate :dedent: 8 """ - if not (policy.san_emails or policy.san_user_principal_names or policy.san_dns_names or policy.subject): + if not ( + policy.san_emails + or policy.san_user_principal_names + or policy.san_dns_names + or policy.san_ip_addresses + or policy.san_uris + or policy.subject + ): raise ValueError(NO_SAN_OR_SUBJECT) polling_interval = kwargs.pop("_polling_interval", None) diff --git a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_version.py b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_version.py index 1166529c1953..de39d939367f 100644 --- a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_version.py +++ b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. # ------------------------------------ -VERSION = "4.11.0" +VERSION = "4.11.1" diff --git a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/aio/_client.py b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/aio/_client.py index bd6288a39369..96efea0105c8 100644 --- a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/aio/_client.py +++ b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/aio/_client.py @@ -96,7 +96,14 @@ async def create_certificate( :caption: Create a certificate :dedent: 8 """ - if not (policy.san_emails or policy.san_user_principal_names or policy.san_dns_names or policy.subject): + if not ( + policy.san_emails + or policy.san_user_principal_names + or policy.san_dns_names + or policy.san_ip_addresses + or policy.san_uris + or policy.subject + ): raise ValueError(NO_SAN_OR_SUBJECT) polling_interval = kwargs.pop("_polling_interval", None) diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/_async_test_case.py b/sdk/keyvault/azure-keyvault-certificates/tests/_async_test_case.py index 6416819682f3..488356369958 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/_async_test_case.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/_async_test_case.py @@ -20,9 +20,20 @@ def __init__(self, **kwargs) -> None: self.is_logging_enabled = kwargs.pop("logging_enable", True) if is_live(): - os.environ["AZURE_TENANT_ID"] = os.getenv("KEYVAULT_TENANT_ID", "") # empty in pipelines - os.environ["AZURE_CLIENT_ID"] = os.getenv("KEYVAULT_CLIENT_ID", "") # empty in pipelines - os.environ["AZURE_CLIENT_SECRET"] = os.getenv("KEYVAULT_CLIENT_SECRET", "") # empty for user-based auth + # Only set AZURE_* vars if the KEYVAULT_* counterpart is non-empty. + # Setting them to empty strings causes EnvironmentCredential to attempt (and fail) + # ClientSecretCredential construction. Removing them lets DefaultAzureCredential + # fall through to AzureCliCredential for developer/interactive auth. + for keyvault_var, azure_var in ( + ("KEYVAULT_TENANT_ID", "AZURE_TENANT_ID"), + ("KEYVAULT_CLIENT_ID", "AZURE_CLIENT_ID"), + ("KEYVAULT_CLIENT_SECRET", "AZURE_CLIENT_SECRET"), + ): + value = os.getenv(keyvault_var, "") + if value: + os.environ[azure_var] = value + else: + os.environ.pop(azure_var, None) def __call__(self, fn): async def _preparer(test_class, api_version, **kwargs): diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/_test_case.py b/sdk/keyvault/azure-keyvault-certificates/tests/_test_case.py index 5dcc9d819140..1f2f7f1f1a25 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/_test_case.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/_test_case.py @@ -25,9 +25,20 @@ def __init__(self, **kwargs) -> None: if is_live(): self.azure_keyvault_url = os.environ["AZURE_KEYVAULT_URL"] - os.environ["AZURE_TENANT_ID"] = os.getenv("KEYVAULT_TENANT_ID", "") # empty in pipelines - os.environ["AZURE_CLIENT_ID"] = os.getenv("KEYVAULT_CLIENT_ID", "") # empty in pipelines - os.environ["AZURE_CLIENT_SECRET"] = os.getenv("KEYVAULT_CLIENT_SECRET", "") # empty for user-based auth + # Only set AZURE_* vars if the KEYVAULT_* counterpart is non-empty. + # Setting them to empty strings causes EnvironmentCredential to attempt (and fail) + # ClientSecretCredential construction. Removing them lets DefaultAzureCredential + # fall through to AzureCliCredential for developer/interactive auth. + for keyvault_var, azure_var in ( + ("KEYVAULT_TENANT_ID", "AZURE_TENANT_ID"), + ("KEYVAULT_CLIENT_ID", "AZURE_CLIENT_ID"), + ("KEYVAULT_CLIENT_SECRET", "AZURE_CLIENT_SECRET"), + ): + value = os.getenv(keyvault_var, "") + if value: + os.environ[azure_var] = value + else: + os.environ.pop(azure_var, None) def __call__(self, fn): def _preparer(test_class, api_version, **kwargs): diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/conftest.py b/sdk/keyvault/azure-keyvault-certificates/tests/conftest.py index a6ffda0132a0..80d461e36526 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/conftest.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/conftest.py @@ -89,6 +89,10 @@ def immediate_return(_): @pytest.fixture(scope="session") def event_loop(request): - loop = asyncio.get_event_loop() + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) yield loop loop.close() diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client.py b/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client.py index 74932f3cffa8..64328d69d73f 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client.py @@ -8,7 +8,7 @@ import time from unittest.mock import Mock, patch -from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError +from azure.core.exceptions import HttpResponseError, ResourceExistsError, ResourceNotFoundError, ServiceRequestError from azure.core.pipeline.policies import SansIOHTTPPolicy from devtools_testutils import recorded_by_proxy from azure.keyvault.certificates import ( @@ -763,6 +763,41 @@ def run(*_, **__): with pytest.raises(ResourceExistsError): client.begin_create_certificate("...", CertificatePolicy.get_default()) + @pytest.mark.parametrize("api_version", only_latest) + @CertificatesClientPreparer() + @recorded_by_proxy + def test_create_certificate_with_san_ip_and_uris(self, client, **kwargs): + """Verify certificates with only san_ip_addresses or san_uris (no subject/dns) can be created.""" + # Certificate with only IP addresses in SANs + ip_cert_name = self.get_resource_name("sanIpCert") + ip_policy = CertificatePolicy( + issuer_name=WellKnownIssuerNames.self, + san_ip_addresses=["10.0.0.1", "192.168.1.1"], + content_type=CertificateContentType.pkcs12, + ) + ip_cert = client.begin_create_certificate(certificate_name=ip_cert_name, policy=ip_policy).result() + assert ip_cert.name == ip_cert_name + returned_ip_policy = client.get_certificate_policy(ip_cert_name) + assert set(returned_ip_policy.san_ip_addresses) == {"10.0.0.1", "192.168.1.1"} + assert not returned_ip_policy.san_dns_names + assert not returned_ip_policy.san_uris + client.begin_delete_certificate(ip_cert_name).wait() + + # Certificate with only URIs in SANs + uri_cert_name = self.get_resource_name("sanUriCert") + uri_policy = CertificatePolicy( + issuer_name=WellKnownIssuerNames.self, + san_uris=["https://service.example.com/api"], + content_type=CertificateContentType.pkcs12, + ) + uri_cert = client.begin_create_certificate(certificate_name=uri_cert_name, policy=uri_policy).result() + assert uri_cert.name == uri_cert_name + returned_uri_policy = client.get_certificate_policy(uri_cert_name) + assert returned_uri_policy.san_uris + assert not returned_uri_policy.san_dns_names + assert not returned_uri_policy.san_ip_addresses + client.begin_delete_certificate(uri_cert_name).wait() + @pytest.mark.parametrize("api_version", only_latest) @CertificatesClientPreparer() @recorded_by_proxy @@ -804,6 +839,24 @@ def test_policy_expected_errors_for_create_cert(): policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self) client.begin_create_certificate("...", policy=policy) + # san_ip_addresses alone should be accepted (no ValueError) + policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self, san_ip_addresses=["10.0.0.1"]) + try: + client.begin_create_certificate("...", policy=policy) + except ValueError: + pytest.fail("begin_create_certificate should not raise ValueError for san_ip_addresses-only policy") + except (HttpResponseError, ServiceRequestError): + pass # Expected: network/auth error since we are using a fake client + + # san_uris alone should be accepted (no ValueError) + policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self, san_uris=["https://example.com"]) + try: + client.begin_create_certificate("...", policy=policy) + except ValueError: + pytest.fail("begin_create_certificate should not raise ValueError for san_uris-only policy") + except (HttpResponseError, ServiceRequestError): + pass # Expected: network/auth error since we are using a fake client + def test_service_headers_allowed_in_logs(): service_headers = {"x-ms-keyvault-network-info", "x-ms-keyvault-region", "x-ms-keyvault-service-version"} diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client_async.py b/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client_async.py index 8b0c2955451b..6333bdeab1b9 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client_async.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/test_certificates_client_async.py @@ -8,7 +8,7 @@ import json from unittest.mock import Mock, patch -from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError +from azure.core.exceptions import HttpResponseError, ResourceExistsError, ResourceNotFoundError, ServiceRequestError from azure.core.pipeline.policies import SansIOHTTPPolicy from devtools_testutils import set_bodiless_matcher, set_custom_default_matcher from devtools_testutils.aio import recorded_by_proxy_async @@ -787,6 +787,43 @@ async def run(*_, **__): await client.create_certificate("...", CertificatePolicy.get_default()) await client.close() + @pytest.mark.asyncio + @pytest.mark.parametrize("api_version", only_latest) + @AsyncCertificatesClientPreparer() + @recorded_by_proxy_async + async def test_create_certificate_with_san_ip_and_uris(self, client, **kwargs): + """Verify certificates with only san_ip_addresses or san_uris (no subject/dns) can be created.""" + # Certificate with only IP addresses in SANs + ip_cert_name = self.get_resource_name("sanIpCert") + ip_policy = CertificatePolicy( + issuer_name=WellKnownIssuerNames.self, + san_ip_addresses=["10.0.0.1", "192.168.1.1"], + content_type=CertificateContentType.pkcs12, + ) + ip_cert = await client.create_certificate(certificate_name=ip_cert_name, policy=ip_policy) + assert ip_cert.name == ip_cert_name + returned_ip_policy = await client.get_certificate_policy(ip_cert_name) + assert set(returned_ip_policy.san_ip_addresses) == {"10.0.0.1", "192.168.1.1"} + assert not returned_ip_policy.san_dns_names + assert not returned_ip_policy.san_uris + await client.delete_certificate(ip_cert_name) + + # Certificate with only URIs in SANs + uri_cert_name = self.get_resource_name("sanUriCert") + uri_policy = CertificatePolicy( + issuer_name=WellKnownIssuerNames.self, + san_uris=["https://service.example.com/api"], + content_type=CertificateContentType.pkcs12, + ) + uri_cert = await client.create_certificate(certificate_name=uri_cert_name, policy=uri_policy) + assert uri_cert.name == uri_cert_name + returned_uri_policy = await client.get_certificate_policy(uri_cert_name) + assert returned_uri_policy.san_uris + assert not returned_uri_policy.san_dns_names + assert not returned_uri_policy.san_ip_addresses + await client.delete_certificate(uri_cert_name) + await client.close() + @pytest.mark.asyncio @pytest.mark.parametrize("api_version", only_latest) @AsyncCertificatesClientPreparer() @@ -827,6 +864,24 @@ async def test_policy_expected_errors_for_create_cert(): policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self) await client.create_certificate("...", policy=policy) + # san_ip_addresses alone should be accepted (no ValueError) + policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self, san_ip_addresses=["10.0.0.1"]) + try: + await client.create_certificate("...", policy=policy) + except ValueError: + pytest.fail("create_certificate should not raise ValueError for san_ip_addresses-only policy") + except (HttpResponseError, ServiceRequestError): + pass # Expected: network/auth error since we are using a fake client + + # san_uris alone should be accepted (no ValueError) + policy = CertificatePolicy(issuer_name=WellKnownIssuerNames.self, san_uris=["https://example.com"]) + try: + await client.create_certificate("...", policy=policy) + except ValueError: + pytest.fail("create_certificate should not raise ValueError for san_uris-only policy") + except (HttpResponseError, ServiceRequestError): + pass # Expected: network/auth error since we are using a fake client + def test_service_headers_allowed_in_logs(): service_headers = {"x-ms-keyvault-network-info", "x-ms-keyvault-region", "x-ms-keyvault-service-version"}