Skip to content

Commit de0f681

Browse files
mrm9084Copilotavanigupta
authored
App Config - Audience Policy (Azure#44221)
* Audience Policy * Update sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_audience_policy.py Co-authored-by: Copilot <[email protected]> * Update test_audience_policy.py * code review items * Update _audience_policy.py * Update test_audience_policy.py * Update _audience_policy.py * Update sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_audience_policy.py Co-authored-by: Avani Gupta <[email protected]> * Updating class name * fixing kwarg get after merge * Update _audience_policy.py * rename + review comments * Update test_audience_policy.py * merge fixes * small fixes plus lives tests * review comments * updated async tests * Revert policy ordering * fixing cspell issue * fixing recorded tests * Removing test as it as there is no way to record it * Update assets.json * Update test_audience_policy.py --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Avani Gupta <[email protected]>
1 parent 0eb9b34 commit de0f681

12 files changed

+312
-29
lines changed

sdk/appconfiguration/azure-appconfiguration/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features Added
66

7+
- Fixed AudiencePolicy to correctly handle AAD audience errors and return ClientAuthenticationError as expected.
78
- Added `match_conditions` parameter to `by_page()` method in `list_configuration_settings()` to efficiently monitor configuration changes using etags without fetching unchanged data.
89
- Added query parameter normalization to support Azure Front Door as a CDN. Query parameter keys are now converted to lowercase and sorted alphabetically.
910
- Added support for custom authentication audiences via the `audience` keyword argument in `AzureAppConfigurationClient` constructor to enable authentication against sovereign clouds.

sdk/appconfiguration/azure-appconfiguration/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration",
5-
"Tag": "python/appconfiguration/azure-appconfiguration_18aed813de"
5+
"Tag": "python/appconfiguration/azure-appconfiguration_7b8ff3a790"
66
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
import sys
7+
from azure.core.exceptions import ClientAuthenticationError
8+
from azure.core.pipeline import PipelineRequest
9+
from azure.core.pipeline.policies import SansIOHTTPPolicy
10+
11+
NO_AUDIENCE_ERROR_MESSAGE = (
12+
"Unable to authenticate to Azure App Configuration. No authentication token audience was provided. Please set the "
13+
"audience via 'audience' when constructing AzureAppConfigurationClient for the target cloud. For details on "
14+
"how to configure the authentication token audience visit https://aka.ms/appconfig/client-token-audience."
15+
)
16+
INCORRECT_AUDIENCE_ERROR_MESSAGE = (
17+
"Unable to authenticate to Azure App Configuration. An incorrect token audience was provided. Please set the "
18+
"audience via 'audience' when constructing AzureAppConfigurationClient to the appropriate audience for this "
19+
"cloud. For details on how to configure the authentication token audience visit "
20+
"https://aka.ms/appconfig/client-token-audience."
21+
)
22+
AAD_AUDIENCE_ERROR_CODE = "AADSTS500011"
23+
24+
25+
class AudienceErrorHandlingPolicy(SansIOHTTPPolicy):
26+
"""
27+
A policy to handle audience-related authentication errors for Azure App Configuration.
28+
Raises a ClientAuthenticationError with a helpful message if the audience is missing or incorrect.
29+
"""
30+
31+
def __init__(self, has_audience: bool = False):
32+
"""
33+
Initialize the AudienceErrorHandlingPolicy.
34+
35+
:param has_audience: Indicates if the expected audience is set for the authentication token.
36+
:type has_audience: bool
37+
"""
38+
self.has_audience = has_audience
39+
40+
def on_exception(self, request: PipelineRequest):
41+
"""
42+
Handles exceptions raised during pipeline execution.
43+
If the exception is a ClientAuthenticationError related to audience, raises a more descriptive error.
44+
45+
:param request: The pipeline request object.
46+
:type request: ~azure.core.pipeline.PipelineRequest
47+
:return: The exception if not handled, otherwise raises a new error.
48+
"""
49+
ex_type, ex_value, _ = sys.exc_info()
50+
if ex_type is None:
51+
return None
52+
if ex_type is ClientAuthenticationError and isinstance(ex_value, ClientAuthenticationError):
53+
self._handle_audience_exception(ex_value)
54+
return ex_value
55+
56+
def _handle_audience_exception(self, ex: ClientAuthenticationError):
57+
"""
58+
Checks the exception message for audience errors and raises a descriptive ClientAuthenticationError.
59+
60+
:param ex: The exception to check and potentially re-raise.
61+
:type ex: ClientAuthenticationError
62+
"""
63+
if ex.message and AAD_AUDIENCE_ERROR_CODE in ex.message:
64+
message = INCORRECT_AUDIENCE_ERROR_MESSAGE if self.has_audience else NO_AUDIENCE_ERROR_MESSAGE
65+
err = ClientAuthenticationError(message, ex.response)
66+
err.message = message
67+
raise err

sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_azure_appconfiguration_client.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
get_label_filter,
4141
parse_connection_string,
4242
)
43+
4344
from ._sync_token import SyncTokenPolicy
45+
from ._audience_error_handling_policy import AudienceErrorHandlingPolicy
4446

4547

4648
class AzureAppConfigurationClient:
@@ -72,7 +74,14 @@ def __init__(self, base_url: str, credential: TokenCredential, **kwargs: Any) ->
7274
self._sync_token_policy = SyncTokenPolicy()
7375
self._query_param_policy = QueryParamPolicy()
7476

75-
audience = kwargs.pop("audience", get_audience(base_url))
77+
audience = kwargs.pop("audience", None)
78+
79+
audience_policy = AudienceErrorHandlingPolicy(bool(audience))
80+
per_call_policies = [self._query_param_policy, self._sync_token_policy, audience_policy]
81+
82+
if audience is None:
83+
audience = get_audience(base_url)
84+
7685
# Ensure all scopes end with /.default and strip any trailing slashes before adding suffix
7786
kwargs["credential_scopes"] = [audience + DEFAULT_SCOPE_SUFFIX]
7887

@@ -97,10 +106,7 @@ def __init__(self, base_url: str, credential: TokenCredential, **kwargs: Any) ->
97106
)
98107
# mypy doesn't compare the credential type hint with the API surface in patch.py
99108
self._impl = AzureAppConfigurationClientGenerated(
100-
base_url,
101-
credential,
102-
per_call_policies=[self._query_param_policy, self._sync_token_policy],
103-
**kwargs, # type: ignore[arg-type]
109+
base_url, credential, per_call_policies=per_call_policies, **kwargs # type: ignore[arg-type]
104110
)
105111

106112
@classmethod

sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/aio/_azure_appconfiguration_client_async.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
get_label_filter,
4444
parse_connection_string,
4545
)
46+
from .._audience_error_handling_policy import AudienceErrorHandlingPolicy
4647

4748

4849
class AzureAppConfigurationClient:
@@ -77,7 +78,14 @@ def __init__(self, base_url: str, credential: AsyncTokenCredential, **kwargs: An
7778
self._sync_token_policy = AsyncSyncTokenPolicy()
7879
self._query_param_policy = QueryParamPolicy()
7980

80-
audience = kwargs.pop("audience", get_audience(base_url))
81+
audience = kwargs.pop("audience", None)
82+
83+
audience_policy = AudienceErrorHandlingPolicy(bool(audience))
84+
per_call_policies = [self._query_param_policy, self._sync_token_policy, audience_policy]
85+
86+
if audience is None:
87+
audience = get_audience(base_url)
88+
8189
# Ensure all scopes end with /.default and strip any trailing slashes before adding suffix
8290
kwargs["credential_scopes"] = [audience + DEFAULT_SCOPE_SUFFIX]
8391

@@ -102,10 +110,7 @@ def __init__(self, base_url: str, credential: AsyncTokenCredential, **kwargs: An
102110
)
103111
# mypy doesn't compare the credential type hint with the API surface in patch.py
104112
self._impl = AzureAppConfigurationClientGenerated(
105-
base_url,
106-
credential,
107-
per_call_policies=[self._query_param_policy, self._sync_token_policy],
108-
**kwargs, # type: ignore[arg-type]
113+
base_url, credential, per_call_policies=per_call_policies, **kwargs # type: ignore[arg-type]
109114
)
110115

111116
@classmethod

sdk/appconfiguration/azure-appconfiguration/tests/asynctestcase.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313

1414
class AsyncAppConfigTestCase(AppConfigTestCase):
15-
def create_aad_client(self, appconfiguration_endpoint_string):
15+
def create_aad_client(self, appconfiguration_endpoint_string, audience=None):
1616
cred = self.get_credential(AzureAppConfigurationClient, is_async=True)
17-
return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred)
17+
return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, audience=audience)
1818

1919
def create_client(self, appconfiguration_connection_string):
2020
return AzureAppConfigurationClient.from_connection_string(appconfiguration_connection_string)
@@ -41,6 +41,15 @@ async def set_up(self, appconfiguration_string, is_aad=False):
4141

4242
async def tear_down(self):
4343
if self.client is not None:
44+
# Archive all ready snapshots
45+
snapshots = self.client.list_snapshots(status=["ready"])
46+
async for snapshot in snapshots:
47+
try:
48+
await self.client.archive_snapshot(name=snapshot.name)
49+
except Exception:
50+
pass
51+
52+
# Delete all configuration settings
4453
config_settings = self.client.list_configuration_settings()
4554
async for config_setting in config_settings:
4655
await self.client.delete_configuration_setting(key=config_setting.key, label=config_setting.label)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
import pytest
7+
from azure.core.exceptions import ClientAuthenticationError
8+
from azure.appconfiguration._audience_error_handling_policy import (
9+
AudienceErrorHandlingPolicy,
10+
INCORRECT_AUDIENCE_ERROR_MESSAGE,
11+
)
12+
from testcase import AppConfigTestCase
13+
from preparers import app_config_aad_decorator
14+
from devtools_testutils import recorded_by_proxy
15+
16+
# cspell:disable-next-line
17+
CORRECT_AUDIENCE = "https://azconfig.io"
18+
# cspell:disable-next-line
19+
VALID_ENDPOINT_SUFFIX = ".azconfig.io"
20+
# cspell:disable-next-line
21+
INVALID_ENDPOINT_SUFFIX = ".azconfig.sovcloud-api.fr"
22+
# cspell:disable-next-line
23+
INCORRECT_AUDIENCE = "https://login.sovcloud-identity2.fr"
24+
25+
26+
class TestAudienceErrorHandlingLive(AppConfigTestCase):
27+
"""Live and recorded tests for audience error handling with the Azure App Configuration client."""
28+
29+
@app_config_aad_decorator
30+
@recorded_by_proxy
31+
def test_client_has_audience_policy_with_no_audience(self, appconfiguration_endpoint_string):
32+
"""Test that client created without audience has policy with has_audience=False."""
33+
# Create client without audience
34+
client = self.create_aad_client(appconfiguration_endpoint_string)
35+
36+
# Check that audience error handling policy is in the pipeline
37+
policies = client._impl._client._pipeline._impl_policies
38+
audience_policy = None
39+
for policy in policies:
40+
# Policies are wrapped in _SansIOHTTPPolicyRunner, so we need to access the underlying policy
41+
underlying_policy = getattr(policy, "_policy", policy)
42+
if isinstance(underlying_policy, AudienceErrorHandlingPolicy):
43+
audience_policy = underlying_policy
44+
break
45+
46+
assert audience_policy is not None, "AudienceErrorHandlingPolicy should be in pipeline"
47+
assert audience_policy.has_audience is False, "has_audience should be False when no audience provided"
48+
49+
@app_config_aad_decorator
50+
@recorded_by_proxy
51+
def test_client_has_audience_policy_with_audience(self, appconfiguration_endpoint_string):
52+
"""Test that client created with audience has policy with has_audience=True."""
53+
# Create client with audience
54+
client = self.create_aad_client(appconfiguration_endpoint_string, audience=CORRECT_AUDIENCE)
55+
56+
# Check that audience error handling policy is in the pipeline
57+
policies = client._impl._client._pipeline._impl_policies
58+
audience_policy = None
59+
for policy in policies:
60+
# Policies are wrapped in _SansIOHTTPPolicyRunner, so we need to access the underlying policy
61+
underlying_policy = getattr(policy, "_policy", policy)
62+
if isinstance(underlying_policy, AudienceErrorHandlingPolicy):
63+
audience_policy = underlying_policy
64+
break
65+
66+
assert audience_policy is not None, "AudienceErrorHandlingPolicy should be in pipeline"
67+
assert audience_policy.has_audience is True, "has_audience should be True when audience provided"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
import pytest
7+
from azure.core.exceptions import ClientAuthenticationError
8+
from azure.appconfiguration._audience_error_handling_policy import (
9+
AudienceErrorHandlingPolicy,
10+
INCORRECT_AUDIENCE_ERROR_MESSAGE,
11+
)
12+
from asynctestcase import AsyncAppConfigTestCase
13+
from async_preparers import app_config_aad_decorator_async
14+
from devtools_testutils.aio import recorded_by_proxy_async
15+
16+
# cspell:disable-next-line
17+
CORRECT_AUDIENCE = "https://azconfig.io"
18+
# cspell:disable-next-line
19+
VALID_ENDPOINT_SUFFIX = ".azconfig.io"
20+
# cspell:disable-next-line
21+
INVALID_ENDPOINT_SUFFIX = ".azconfig.sovcloud-api.fr"
22+
# cspell:disable-next-line
23+
INCORRECT_AUDIENCE = "https://login.sovcloud-identity2.fr"
24+
25+
26+
class TestAudienceErrorHandlingLiveAsync(AsyncAppConfigTestCase):
27+
"""Async live and recorded tests for audience error handling with the Azure App Configuration client."""
28+
29+
@app_config_aad_decorator_async
30+
@recorded_by_proxy_async
31+
async def test_async_client_has_audience_policy_with_no_audience(self, appconfiguration_endpoint_string):
32+
"""Test that async client created without audience has policy with has_audience=False."""
33+
# Create client without audience
34+
client = self.create_aad_client(appconfiguration_endpoint_string)
35+
36+
# Check that audience error handling policy is in the pipeline
37+
policies = client._impl._client._pipeline._impl_policies
38+
audience_policy = None
39+
for policy in policies:
40+
# Policies are wrapped in _SansIOHTTPPolicyRunner, so we need to access the underlying policy
41+
underlying_policy = getattr(policy, "_policy", policy)
42+
if isinstance(underlying_policy, AudienceErrorHandlingPolicy):
43+
audience_policy = underlying_policy
44+
break
45+
46+
assert audience_policy is not None, "AudienceErrorHandlingPolicy should be in pipeline"
47+
assert audience_policy.has_audience is False, "has_audience should be False when no audience provided"
48+
49+
await client.close()
50+
51+
@app_config_aad_decorator_async
52+
@recorded_by_proxy_async
53+
async def test_async_client_has_audience_policy_with_audience(self, appconfiguration_endpoint_string):
54+
"""Test that async client created with audience has policy with has_audience=True."""
55+
# Create client with audience
56+
client = self.create_aad_client(appconfiguration_endpoint_string, audience=CORRECT_AUDIENCE)
57+
58+
# Check that audience error handling policy is in the pipeline
59+
policies = client._impl._client._pipeline._impl_policies
60+
audience_policy = None
61+
for policy in policies:
62+
# Policies are wrapped in _SansIOHTTPPolicyRunner, so we need to access the underlying policy
63+
underlying_policy = getattr(policy, "_policy", policy)
64+
if isinstance(underlying_policy, AudienceErrorHandlingPolicy):
65+
audience_policy = underlying_policy
66+
break
67+
68+
assert audience_policy is not None, "AudienceErrorHandlingPolicy should be in pipeline"
69+
assert audience_policy.has_audience is True, "has_audience should be True when audience provided"
70+
71+
await client.close()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from azure.core.exceptions import ClientAuthenticationError
7+
from azure.appconfiguration._audience_error_handling_policy import (
8+
AudienceErrorHandlingPolicy,
9+
AAD_AUDIENCE_ERROR_CODE,
10+
NO_AUDIENCE_ERROR_MESSAGE,
11+
INCORRECT_AUDIENCE_ERROR_MESSAGE,
12+
)
13+
import pytest
14+
15+
16+
def test_on_exception_no_audience():
17+
policy = AudienceErrorHandlingPolicy(False)
18+
try:
19+
raise ClientAuthenticationError(message=f"{AAD_AUDIENCE_ERROR_CODE} some error", response=None)
20+
except ClientAuthenticationError:
21+
with pytest.raises(ClientAuthenticationError) as exc_info:
22+
policy.on_exception(None)
23+
assert NO_AUDIENCE_ERROR_MESSAGE in str(exc_info.value)
24+
25+
26+
def test_on_exception_incorrect_audience():
27+
policy = AudienceErrorHandlingPolicy(True)
28+
try:
29+
raise ClientAuthenticationError(message=f"{AAD_AUDIENCE_ERROR_CODE} some error", response=None)
30+
except ClientAuthenticationError:
31+
with pytest.raises(ClientAuthenticationError) as exc_info:
32+
policy.on_exception(None)
33+
assert INCORRECT_AUDIENCE_ERROR_MESSAGE in str(exc_info.value)
34+
35+
36+
def test_on_exception_non_audience_error():
37+
policy = AudienceErrorHandlingPolicy(False)
38+
try:
39+
raise ClientAuthenticationError(message="Some other error", response=None)
40+
except ClientAuthenticationError as ex:
41+
result = policy.on_exception(None)
42+
assert result is ex

0 commit comments

Comments
 (0)