Skip to content

Commit bcbdd2a

Browse files
Add Azure Key Vault settings source (#272)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 6ffd6bd commit bcbdd2a

File tree

9 files changed

+333
-28
lines changed

9 files changed

+333
-28
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ refresh-lockfiles:
1313
find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete
1414
pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/linting.txt requirements/linting.in
1515
pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/testing.txt requirements/testing.in
16-
pip-compile -q --no-emit-index-url --resolver backtracking --extra toml --extra yaml -o requirements/pyproject.txt pyproject.toml
16+
pip-compile -q --no-emit-index-url --resolver backtracking --extra toml --extra yaml --extra azure-key-vault -o requirements/pyproject.txt pyproject.toml
1717
pip install --dry-run -r requirements/all.txt
1818

1919
.PHONY: format

docs/index.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,65 @@ Last, run your application inside a Docker container and supply your newly creat
11731173
docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest
11741174
```
11751175

1176+
## Azure Key Vault
1177+
1178+
You must set two parameters:
1179+
1180+
- `url`: For example, `https://my-resource.vault.azure.net/`.
1181+
- `credential`: If you use `DefaultAzureCredential`, in local you can execute `az login` to get your identity credentials. The identity must have a role assignment (the recommended one is `Key Vault Secrets User`), so you can access the secrets.
1182+
1183+
You must have the same naming convention in the field name as in the Key Vault secret name. For example, if the secret is named `SqlServerPassword`, the field name must be the same. You can use an alias too.
1184+
1185+
In Key Vault, nested models are supported with the `--` separator. For example, `SqlServer--Password`.
1186+
1187+
Key Vault arrays (e.g. `MySecret--0`, `MySecret--1`) are not supported.
1188+
1189+
```py
1190+
import os
1191+
from typing import Tuple, Type
1192+
1193+
from azure.identity import DefaultAzureCredential
1194+
from pydantic import BaseModel
1195+
1196+
from pydantic_settings import (
1197+
AzureKeyVaultSettingsSource,
1198+
BaseSettings,
1199+
PydanticBaseSettingsSource,
1200+
)
1201+
1202+
1203+
class SubModel(BaseModel):
1204+
a: str
1205+
1206+
1207+
class AzureKeyVaultSettings(BaseSettings):
1208+
foo: str
1209+
bar: int
1210+
sub: SubModel
1211+
1212+
@classmethod
1213+
def settings_customise_sources(
1214+
cls,
1215+
settings_cls: Type[BaseSettings],
1216+
init_settings: PydanticBaseSettingsSource,
1217+
env_settings: PydanticBaseSettingsSource,
1218+
dotenv_settings: PydanticBaseSettingsSource,
1219+
file_secret_settings: PydanticBaseSettingsSource,
1220+
) -> Tuple[PydanticBaseSettingsSource, ...]:
1221+
az_key_vault_settings = AzureKeyVaultSettingsSource(
1222+
settings_cls,
1223+
os.environ['AZURE_KEY_VAULT_URL'],
1224+
DefaultAzureCredential(),
1225+
)
1226+
return (
1227+
init_settings,
1228+
env_settings,
1229+
dotenv_settings,
1230+
file_secret_settings,
1231+
az_key_vault_settings,
1232+
)
1233+
```
1234+
11761235
## Other settings source
11771236

11781237
Other settings sources are available for common configuration files:

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .main import BaseSettings, SettingsConfigDict
22
from .sources import (
3+
AzureKeyVaultSettingsSource,
34
CliPositionalArg,
45
CliSettingsSource,
56
CliSubCommand,
@@ -30,6 +31,7 @@
3031
'SettingsConfigDict',
3132
'TomlConfigSettingsSource',
3233
'YamlConfigSettingsSource',
34+
'AzureKeyVaultSettingsSource',
3335
'__version__',
3436
)
3537

pydantic_settings/sources.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
Any,
2020
Callable,
2121
Generic,
22+
Iterator,
2223
List,
2324
Mapping,
25+
Optional,
2426
Sequence,
2527
Tuple,
2628
TypeVar,
@@ -83,6 +85,21 @@ def import_toml() -> None:
8385
import tomllib
8486

8587

88+
def import_azure_key_vault() -> None:
89+
global TokenCredential
90+
global SecretClient
91+
global ResourceNotFoundError
92+
93+
try:
94+
from azure.core.credentials import TokenCredential
95+
from azure.core.exceptions import ResourceNotFoundError
96+
from azure.keyvault.secrets import SecretClient
97+
except ImportError as e:
98+
raise ImportError(
99+
'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`'
100+
) from e
101+
102+
86103
DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
87104
PathType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
88105
DEFAULT_PATH: PathType = Path('')
@@ -1725,6 +1742,70 @@ def _read_file(self, file_path: Path) -> dict[str, Any]:
17251742
return yaml.safe_load(yaml_file) or {}
17261743

17271744

1745+
class AzureKeyVaultMapping(Mapping[str, Optional[str]]):
1746+
_loaded_secrets: dict[str, str | None]
1747+
_secret_client: SecretClient # type: ignore
1748+
_secret_names: list[str]
1749+
1750+
def __init__(
1751+
self,
1752+
secret_client: SecretClient, # type: ignore
1753+
) -> None:
1754+
self._loaded_secrets = {}
1755+
self._secret_client = secret_client
1756+
self._secret_names: list[str] = [secret.name for secret in self._secret_client.list_properties_of_secrets()]
1757+
1758+
def __getitem__(self, key: str) -> str | None:
1759+
if key not in self._loaded_secrets:
1760+
try:
1761+
self._loaded_secrets[key] = self._secret_client.get_secret(key).value
1762+
except ResourceNotFoundError: # type: ignore
1763+
raise KeyError(key)
1764+
1765+
return self._loaded_secrets[key]
1766+
1767+
def __len__(self) -> int:
1768+
return len(self._secret_names)
1769+
1770+
def __iter__(self) -> Iterator[str]:
1771+
return iter(self._secret_names)
1772+
1773+
1774+
class AzureKeyVaultSettingsSource(EnvSettingsSource):
1775+
_url: str
1776+
_credential: TokenCredential # type: ignore
1777+
_secret_client: SecretClient # type: ignore
1778+
1779+
def __init__(
1780+
self,
1781+
settings_cls: type[BaseSettings],
1782+
url: str,
1783+
credential: TokenCredential, # type: ignore
1784+
env_prefix: str | None = None,
1785+
env_parse_none_str: str | None = None,
1786+
env_parse_enums: bool | None = None,
1787+
) -> None:
1788+
import_azure_key_vault()
1789+
self._url = url
1790+
self._credential = credential
1791+
super().__init__(
1792+
settings_cls,
1793+
case_sensitive=True,
1794+
env_prefix=env_prefix,
1795+
env_nested_delimiter='--',
1796+
env_ignore_empty=False,
1797+
env_parse_none_str=env_parse_none_str,
1798+
env_parse_enums=env_parse_enums,
1799+
)
1800+
1801+
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
1802+
secret_client = SecretClient(vault_url=self._url, credential=self._credential) # type: ignore
1803+
return AzureKeyVaultMapping(secret_client)
1804+
1805+
def __repr__(self) -> str:
1806+
return f'AzureKeyVaultSettingsSource(url={self._url!r}, ' f'env_nested_delimiter={self.env_nested_delimiter!r})'
1807+
1808+
17281809
def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
17291810
return key if case_sensitive else key.lower()
17301811

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dynamic = ['version']
4848
[project.optional-dependencies]
4949
yaml = ["pyyaml>=6.0.1"]
5050
toml = ["tomli>=2.0.1"]
51+
azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]
5152

5253
[project.urls]
5354
Homepage = 'https://github.com/pydantic/pydantic-settings'

requirements/linting.txt

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,43 @@
44
#
55
# pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in
66
#
7-
black==24.4.0
7+
black==24.4.2
88
# via -r requirements/linting.in
99
cfgv==3.4.0
1010
# via pre-commit
1111
click==8.1.7
1212
# via black
1313
distlib==0.3.8
1414
# via virtualenv
15-
filelock==3.13.4
15+
filelock==3.15.3
1616
# via virtualenv
1717
identify==2.5.36
1818
# via pre-commit
19-
mypy==1.9.0
19+
mypy==1.10.0
2020
# via -r requirements/linting.in
2121
mypy-extensions==1.0.0
2222
# via
2323
# black
2424
# mypy
25-
nodeenv==1.8.0
25+
nodeenv==1.9.1
2626
# via pre-commit
27-
packaging==24.0
27+
packaging==24.1
2828
# via black
2929
pathspec==0.12.1
3030
# via black
31-
platformdirs==4.2.0
31+
platformdirs==4.2.2
3232
# via
3333
# black
3434
# virtualenv
3535
pre-commit==3.5.0
3636
# via -r requirements/linting.in
37-
pyupgrade==3.15.2
37+
pyupgrade==3.16.0
3838
# via -r requirements/linting.in
3939
pyyaml==6.0.1
4040
# via
4141
# -r requirements/linting.in
4242
# pre-commit
43-
ruff==0.4.1
43+
ruff==0.4.10
4444
# via -r requirements/linting.in
4545
tokenize-rt==5.2.0
4646
# via pyupgrade
@@ -50,12 +50,9 @@ tomli==2.0.1
5050
# mypy
5151
types-pyyaml==6.0.12.20240311
5252
# via -r requirements/linting.in
53-
typing-extensions==4.11.0
53+
typing-extensions==4.12.2
5454
# via
5555
# black
5656
# mypy
57-
virtualenv==20.25.3
57+
virtualenv==20.26.2
5858
# via pre-commit
59-
60-
# The following packages are considered to be unsafe in a requirements file:
61-
# setuptools

requirements/pyproject.txt

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,74 @@
22
# This file is autogenerated by pip-compile with Python 3.8
33
# by the following command:
44
#
5-
# pip-compile --extra=toml --extra=yaml --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
5+
# pip-compile --extra=azure-key-vault --extra=toml --extra=yaml --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
66
#
7-
annotated-types==0.6.0
7+
annotated-types==0.7.0
88
# via pydantic
9-
pydantic==2.7.0
9+
azure-core==1.30.2
10+
# via
11+
# azure-identity
12+
# azure-keyvault-secrets
13+
azure-identity==1.17.0
14+
# via pydantic-settings (pyproject.toml)
15+
azure-keyvault-secrets==4.8.0
16+
# via pydantic-settings (pyproject.toml)
17+
certifi==2024.6.2
18+
# via requests
19+
cffi==1.16.0
20+
# via cryptography
21+
charset-normalizer==3.3.2
22+
# via requests
23+
cryptography==42.0.8
24+
# via
25+
# azure-identity
26+
# msal
27+
# pyjwt
28+
idna==3.7
29+
# via requests
30+
isodate==0.6.1
31+
# via azure-keyvault-secrets
32+
msal==1.28.1
33+
# via
34+
# azure-identity
35+
# msal-extensions
36+
msal-extensions==1.1.0
37+
# via azure-identity
38+
packaging==24.1
39+
# via msal-extensions
40+
portalocker==2.8.2
41+
# via msal-extensions
42+
pycparser==2.22
43+
# via cffi
44+
pydantic==2.7.4
1045
# via pydantic-settings (pyproject.toml)
11-
pydantic-core==2.18.1
46+
pydantic-core==2.18.4
1247
# via pydantic
48+
pyjwt[crypto]==2.8.0
49+
# via
50+
# msal
51+
# pyjwt
1352
python-dotenv==1.0.1
1453
# via pydantic-settings (pyproject.toml)
1554
pyyaml==6.0.1
1655
# via pydantic-settings (pyproject.toml)
56+
requests==2.32.3
57+
# via
58+
# azure-core
59+
# msal
60+
six==1.16.0
61+
# via
62+
# azure-core
63+
# isodate
1764
tomli==2.0.1
1865
# via pydantic-settings (pyproject.toml)
19-
typing-extensions==4.11.0
66+
typing-extensions==4.12.2
2067
# via
2168
# annotated-types
69+
# azure-core
70+
# azure-identity
71+
# azure-keyvault-secrets
2272
# pydantic
2373
# pydantic-core
74+
urllib3==2.2.2
75+
# via requests

0 commit comments

Comments
 (0)