Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
89c195d
Sync refresh changes
mrm9084 Jun 26, 2025
961efda
Key Vault Refresh
mrm9084 Jun 30, 2025
be0ffa7
adding tests and fixing sync refresh
mrm9084 Jul 1, 2025
1b0f554
Updating Async
mrm9084 Jul 2, 2025
5f54837
Fixed Async Tests
mrm9084 Jul 2, 2025
d5e8b87
Updated tests and change log
mrm9084 Jul 2, 2025
3f838d7
Apply suggestions from code review
mrm9084 Jul 2, 2025
8af8c0e
Merge branch 'main' into KeyVaultRefresh
mrm9084 Jul 2, 2025
251b825
Fixing merge issue
mrm9084 Jul 2, 2025
382dbba
Updating comments
mrm9084 Jul 2, 2025
00985a1
Merge remote-tracking branch 'upstream/main' into KeyVaultRefresh
mrm9084 Aug 8, 2025
ef6620a
Merge branch 'main' into KeyVaultRefresh
mrm9084 Aug 8, 2025
6d7a8f8
Updating secret refresh
mrm9084 Aug 11, 2025
d4a8d71
Merge branch 'KeyVaultRefresh' of https://github.com/mrm9084/azure-sd…
mrm9084 Aug 11, 2025
7ccb2a0
Update _azureappconfigurationproviderasync.py
mrm9084 Aug 11, 2025
2663cb3
Fixing Optional Endpoint
mrm9084 Aug 11, 2025
12fbdda
fix mypy issue
mrm9084 Aug 11, 2025
2758caf
fixing async test
mrm9084 Aug 12, 2025
5d4d6fd
Merge remote-tracking branch 'upstream/main' into KeyVaultRefresh
mrm9084 Aug 21, 2025
f2f279f
mixing merge
mrm9084 Aug 22, 2025
4b7f2c5
fixing test after merge
mrm9084 Aug 22, 2025
7d95da6
Update testcase.py
mrm9084 Aug 22, 2025
bfaaa28
Secret Provider Base
mrm9084 Aug 27, 2025
0ab5e66
removing unused imports
mrm9084 Aug 27, 2025
6d68ba3
updating exception
mrm9084 Aug 27, 2025
15af7f3
updating resolve key vault references
mrm9084 Aug 27, 2025
b160963
Review comments
mrm9084 Aug 28, 2025
2cdff6d
fixing tests
mrm9084 Aug 29, 2025
f49340f
tox updates
mrm9084 Aug 29, 2025
4fb9532
Updating Tests
mrm9084 Aug 29, 2025
6f55701
Updating Async to be the same as sync
mrm9084 Aug 29, 2025
12dc565
Fixing formatting
mrm9084 Aug 29, 2025
2072e76
fixing tox and unneeded ""
mrm9084 Aug 29, 2025
4656f83
fixing tox items
mrm9084 Aug 29, 2025
08e5ada
fix cspell + tests recording
mrm9084 Aug 29, 2025
0599f68
Update test_async_secret_provider.py
mrm9084 Sep 2, 2025
78a0b0d
Merge remote-tracking branch 'upstream/main' into KeyVaultRefresh
mrm9084 Sep 30, 2025
35a05bf
Post Merge updates
mrm9084 Sep 30, 2025
8e04133
Move cache to shared code
mrm9084 Sep 30, 2025
f7ffe3f
removed unneeded disabled
mrm9084 Sep 30, 2025
3ebcf45
Update Secret Provider
mrm9084 Oct 2, 2025
8c2637d
Updating usage
mrm9084 Oct 7, 2025
cfad924
Merge remote-tracking branch 'upstream/main' into KeyVaultRefresh
mrm9084 Oct 8, 2025
8e14697
Update assets.json
mrm9084 Oct 8, 2025
53a0eeb
Updated to make secret refresh update dictionary
mrm9084 Oct 10, 2025
abddfd9
removing _secret_version_cache
mrm9084 Oct 17, 2025
0379631
Merge branch 'main' into KeyVaultRefresh
mrm9084 Oct 17, 2025
d5add3b
Update assets.json
mrm9084 Oct 17, 2025
f10365c
Update _secret_provider_base.py
mrm9084 Oct 20, 2025
1955caf
Update _secret_provider_base.py
mrm9084 Oct 20, 2025
b329284
Update _secret_provider_base.py
mrm9084 Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

### Features Added

* Added support for forced refresh of configurations when using Key Vault references. Adds `secret_refresh_interval` to the `AzureAppConfigurationProvider.load` method. This allows the provider to refresh Key Vault secrets at a specified interval. Is set to 60 seconds by default, and can only be set if using Key Vault references.
* Added support for async `on_refresh_success`.

### Breaking Changes

### Bugs Fixed

* Fixed a bug where feature flags were using the configuration refresh timer instead of the feature flag refresh timer.

### Other Changes

## 2.2.0 (2025-08-08)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "python",
"TagPrefix": "python/appconfiguration/azure-appconfiguration-provider",
"Tag": "python/appconfiguration/azure-appconfiguration-provider_8a72ac47e0"
"Tag": "python/appconfiguration/azure-appconfiguration-provider_30404f3021"
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@
SecretReferenceConfigurationSetting,
)
from azure.core.exceptions import AzureError, HttpResponseError
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector
from ._key_vault._secret_provider import SecretProvider
from ._constants import (
FEATURE_MANAGEMENT_KEY,
FEATURE_FLAG_KEY,
)
from ._azureappconfigurationproviderbase import (
AzureAppConfigurationProviderBase,
update_correlation_context_header,
delay_failure,
sdk_allowed_kwargs,
update_correlation_context_header,
)
from ._client_manager import ConfigurationClientManager, _ConfigurationClientWrapper as ConfigurationClient
from ._user_agent import USER_AGENT
Expand Down Expand Up @@ -240,46 +240,6 @@ def _buildprovider(
return AzureAppConfigurationProvider(**kwargs)


def _resolve_keyvault_reference(
config: "SecretReferenceConfigurationSetting", provider: "AzureAppConfigurationProvider"
) -> str:
# pylint:disable=protected-access
if not (provider._keyvault_credential or provider._keyvault_client_configs or provider._secret_resolver):
raise ValueError(
"""
Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve Key
Vault references.
"""
)

if config.secret_id is None:
raise ValueError("Key Vault reference must have a uri value.")

keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id)

vault_url = keyvault_identifier.vault_url + "/"

# pylint:disable=protected-access
referenced_client = provider._secret_clients.get(vault_url, None)

vault_config = provider._keyvault_client_configs.get(vault_url, {})
credential = vault_config.pop("credential", provider._keyvault_credential)

if referenced_client is None and credential is not None:
referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config)
provider._secret_clients[vault_url] = referenced_client

if referenced_client:
secret_value = referenced_client.get_secret(keyvault_identifier.name, version=keyvault_identifier.version).value
if secret_value is not None:
return secret_value

if provider._secret_resolver:
return provider._secret_resolver(config.secret_id)

raise ValueError("No Secret Client found for Key Vault reference %s" % (vault_url))


class AzureAppConfigurationProvider(AzureAppConfigurationProviderBase): # pylint: disable=too-many-instance-attributes
"""
Provides a dictionary-like interface to Azure App Configuration settings. Enables loading of sets of configuration
Expand All @@ -303,8 +263,8 @@ def __init__(self, **kwargs: Any) -> None:
max_backoff: int = min(kwargs.pop("max_backoff", 600), interval)

self._replica_client_manager = ConfigurationClientManager(
connection_string=kwargs.pop("connection_string", None),
endpoint=kwargs.pop("endpoint", None),
connection_string=kwargs.pop("connection_string"),
endpoint=kwargs.pop("endpoint"),
credential=kwargs.pop("credential", None),
user_agent=user_agent,
retry_total=kwargs.pop("retry_total", 2),
Expand All @@ -315,7 +275,7 @@ def __init__(self, **kwargs: Any) -> None:
load_balancing_enabled=kwargs.pop("load_balancing_enabled", False),
**kwargs,
)
self._secret_clients: Dict[str, SecretClient] = {}
self._secret_provider = SecretProvider(**kwargs)
self._on_refresh_success: Optional[Callable] = kwargs.pop("on_refresh_success", None)
self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None)

Expand All @@ -327,7 +287,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f
replica_count,
self._feature_flag_enabled,
self._feature_filter_usage,
self._uses_key_vault,
self._secret_provider.uses_key_vault,
self._uses_load_balancing,
is_failover_request,
self._uses_ai_configuration,
Expand Down Expand Up @@ -417,6 +377,11 @@ def refresh(self, **kwargs) -> None:
exception: Optional[Exception] = None
is_failover_request = False
try:
if (
self._secret_provider.secret_refresh_timer
and self._secret_provider.secret_refresh_timer.needs_refresh()
):
self._secret_provider.refresh_secrets()
self._replica_client_manager.refresh_clients()
self._replica_client_manager.find_active_clients()
replica_count = self._replica_client_manager.get_client_count() - 1
Expand Down Expand Up @@ -458,7 +423,7 @@ def _load_all(self, **kwargs):
replica_count,
self._feature_flag_enabled,
self._feature_filter_usage,
self._uses_key_vault,
self._secret_provider.uses_key_vault,
self._uses_load_balancing,
is_failover_request,
self._uses_ai_configuration,
Expand Down Expand Up @@ -513,9 +478,13 @@ def _load_all(self, **kwargs):
raise exception

def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]:
# configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take
# precedence. Only process the settings with the highest priority (i.e. the last one in the list).
unique_settings = self._deduplicate_settings(configuration_settings)

configuration_settings_processed = {}
feature_flags_processed = []
for settings in configuration_settings:
for settings in unique_settings.values():
if isinstance(settings, FeatureFlagConfigurationSetting):
# Feature flags are not processed like other settings
feature_flag_value = self._process_feature_flag(settings)
Expand All @@ -531,7 +500,7 @@ def _process_configurations(self, configuration_settings: List[ConfigurationSett

def _process_key_value(self, config: ConfigurationSetting) -> Any:
if isinstance(config, SecretReferenceConfigurationSetting):
return _resolve_keyvault_reference(config, self)
return self._secret_provider.resolve_keyvault_reference(config)
# Use the base class helper method for non-KeyVault processing
return self._process_key_value_base(config)

Expand All @@ -548,17 +517,14 @@ def close(self) -> None:
"""
Closes the connection to Azure App Configuration.
"""
for client in self._secret_clients.values():
client.close()
self._secret_provider.close()
self._replica_client_manager.close()

def __enter__(self) -> "AzureAppConfigurationProvider":
self._replica_client_manager.__enter__()
for client in self._secret_clients.values():
client.__enter__()
self._secret_provider.__enter__()
return self

def __exit__(self, *args) -> None:
self._replica_client_manager.__exit__()
for client in self._secret_clients.values():
client.__exit__()
self._secret_provider.__exit__()
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,11 @@ def __init__(self, **kwargs: Any) -> None:

trim_prefixes: List[str] = kwargs.pop("trim_prefixes", [])
self._trim_prefixes: List[str] = sorted(trim_prefixes, key=len, reverse=True)

refresh_on: List[Tuple[str, str]] = kwargs.pop("refresh_on", None) or []
self._watched_settings: Dict[Tuple[str, str], Optional[str]] = {
_build_watched_setting(s): None for s in refresh_on
}
self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
self._secret_resolver = kwargs.pop("secret_resolver", None)
self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {})
self._uses_key_vault = (
self._keyvault_credential is not None
or (self._keyvault_client_configs is not None and len(self._keyvault_client_configs) > 0)
or self._secret_resolver is not None
)
self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False)
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")])
self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
Expand Down Expand Up @@ -598,3 +589,18 @@ def _update_watched_feature_flags(
for feature_flag in feature_flags:
watched_feature_flags[(feature_flag.key, feature_flag.label)] = feature_flag.etag
return watched_feature_flags

def _deduplicate_settings(
self, configuration_settings: List[ConfigurationSetting]
) -> Dict[str, ConfigurationSetting]:
"""
Deduplicates configuration settings by key.

:param List[ConfigurationSetting] configuration_settings: The list of configuration settings to deduplicate
:return: A dictionary mapping keys to their unique configuration settings
:rtype: Dict[str, ConfigurationSetting]
"""
unique_settings: Dict[str, ConfigurationSetting] = {}
for settings in configuration_settings:
unique_settings[settings.key] = settings
return unique_settings
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------

from ._secret_provider import SecretProvider
from ._secret_provider_base import _SecretProviderBase

__all__ = [
"SecretProvider",
"_SecretProviderBase",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from typing import Mapping, Any, Dict
from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
from azure.core.exceptions import ServiceRequestError
from ._secret_provider_base import _SecretProviderBase

JSON = Mapping[str, Any]


class SecretProvider(_SecretProviderBase):

def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._secret_clients: Dict[str, SecretClient] = {}
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
self._secret_resolver = kwargs.pop("secret_resolver", None)
self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {})

def resolve_keyvault_reference(self, config: SecretReferenceConfigurationSetting) -> str:
keyvault_identifier, vault_url = self.resolve_keyvault_reference_base(config)
if keyvault_identifier.source_id in self._secret_cache:
_, _, value = self._secret_cache[keyvault_identifier.source_id]
return value
if keyvault_identifier.source_id in self._secret_version_cache:
_, _, value = self._secret_version_cache[keyvault_identifier.source_id]
return value

return self.__get_secret_value(config.key, keyvault_identifier, vault_url)

def refresh_secrets(self) -> None:
original_cache = self._secret_cache.copy()
self._secret_cache.clear()
for source_id, (secret_identifier, key, _) in original_cache.items():
self._secret_cache[source_id] = (
secret_identifier,
key,
self.__get_secret_value(key, secret_identifier, secret_identifier.vault_url + "/"),
)

def __get_secret_value(self, key: str, secret_identifier: KeyVaultSecretIdentifier, vault_url: str) -> str:
referenced_client = self._secret_clients.get(vault_url, None)

vault_config = self._keyvault_client_configs.get(vault_url, {})
credential = vault_config.pop("credential", self._keyvault_credential)

if referenced_client is None and credential is not None:
referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config)
self._secret_clients[vault_url] = referenced_client

secret_value = None

if referenced_client:
try:
secret_value = referenced_client.get_secret(
secret_identifier.name, version=secret_identifier.version
).value
except ServiceRequestError as e:
raise ValueError("Failed to retrieve secret from Key Vault") from e

if self._secret_resolver and secret_value is None:
secret_value = self._secret_resolver(secret_identifier.source_id)

return self._cache_value(key, secret_identifier, secret_value)

def close(self) -> None:
"""
Closes the connection to Azure App Configuration.
"""
for client in self._secret_clients.values():
client.close()

def __enter__(self) -> "SecretProvider":
for client in self._secret_clients.values():
client.__enter__()
return self

def __exit__(self, *args) -> None:
for client in self._secret_clients.values():
client.__exit__()
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# ------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from typing import (
Mapping,
Any,
TypeVar,
Optional,
Dict,
Tuple,
)
from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module
from azure.keyvault.secrets import KeyVaultSecretIdentifier
from .._azureappconfigurationproviderbase import _RefreshTimer

JSON = Mapping[str, Any]
_T = TypeVar("_T")


class _SecretProviderBase:

def __init__(self, **kwargs: Any) -> None:
# [source_id, (KeyVaultSecretIdentifier, key, value)]
self._secret_cache: Dict[str, Tuple[KeyVaultSecretIdentifier, str, str]] = {}
self._secret_version_cache: Dict[str, Tuple[KeyVaultSecretIdentifier, str, str]] = {}
self.uses_key_vault = (
"keyvault_credential" in kwargs
or ("keyvault_client_configs" in kwargs and len(kwargs.get("keyvault_client_configs", {})) > 0)
or "secret_resolver" in kwargs
)
self.secret_refresh_timer: Optional[_RefreshTimer] = (
_RefreshTimer(refresh_interval=kwargs.pop("secret_refresh_interval", 60))
if self.uses_key_vault and "secret_refresh_interval" in kwargs
else None
)

def _cache_value(self, key: str, keyvault_identifier: KeyVaultSecretIdentifier, secret_value: Any) -> str:
if secret_value:
if keyvault_identifier.version:
self._secret_version_cache[keyvault_identifier.source_id] = (keyvault_identifier, key, secret_value)
else:
self._secret_cache[keyvault_identifier.source_id] = (keyvault_identifier, key, secret_value)
return secret_value

raise ValueError("No Secret Client found for Key Vault reference %s" % (keyvault_identifier.vault_url))

def resolve_keyvault_reference_base(
self, config: SecretReferenceConfigurationSetting
) -> Tuple[KeyVaultSecretIdentifier, str]:
if not self.uses_key_vault:
raise ValueError(
"""
Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve
Key Vault references.
"""
)

if config.secret_id is None:
raise ValueError("Key Vault reference must have a uri value.")

keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id)

vault_url = keyvault_identifier.vault_url + "/"

return keyvault_identifier, vault_url
Loading