Skip to content

Commit c8a0df4

Browse files
Add support for AWS Secrets Manager (#532)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 01407c0 commit c8a0df4

File tree

8 files changed

+539
-3
lines changed

8 files changed

+539
-3
lines changed

docs/index.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,61 @@ Last, run your application inside a Docker container and supply your newly creat
17751775
docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest
17761776
```
17771777

1778+
## AWS Secrets Manager
1779+
1780+
You must set one parameter:
1781+
1782+
- `secret_id`: The AWS secret id
1783+
1784+
You must have the same naming convention in the key value in secret as in the field name. For example, if the key in secret is named `SqlServerPassword`, the field name must be the same. You can use an alias too.
1785+
1786+
In AWS Secrets Manager, nested models are supported with the `--` separator in the key name. For example, `SqlServer--Password`.
1787+
1788+
Arrays (e.g. `MySecret--0`, `MySecret--1`) are not supported.
1789+
1790+
```py
1791+
import os
1792+
1793+
from pydantic import BaseModel
1794+
1795+
from pydantic_settings import (
1796+
AWSSecretsManagerSettingsSource,
1797+
BaseSettings,
1798+
PydanticBaseSettingsSource,
1799+
)
1800+
1801+
1802+
class SubModel(BaseModel):
1803+
a: str
1804+
1805+
1806+
class AWSSecretsManagerSettings(BaseSettings):
1807+
foo: str
1808+
bar: int
1809+
sub: SubModel
1810+
1811+
@classmethod
1812+
def settings_customise_sources(
1813+
cls,
1814+
settings_cls: type[BaseSettings],
1815+
init_settings: PydanticBaseSettingsSource,
1816+
env_settings: PydanticBaseSettingsSource,
1817+
dotenv_settings: PydanticBaseSettingsSource,
1818+
file_secret_settings: PydanticBaseSettingsSource,
1819+
) -> tuple[PydanticBaseSettingsSource, ...]:
1820+
aws_secrets_manager_settings = AWSSecretsManagerSettingsSource(
1821+
settings_cls,
1822+
os.environ['AWS_SECRETS_MANAGER_SECRET_ID'],
1823+
)
1824+
return (
1825+
init_settings,
1826+
env_settings,
1827+
dotenv_settings,
1828+
file_secret_settings,
1829+
aws_secrets_manager_settings,
1830+
)
1831+
```
1832+
17781833
## Azure Key Vault
17791834

17801835
You must set two parameters:

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .main import BaseSettings, CliApp, SettingsConfigDict
33
from .sources import (
44
CLI_SUPPRESS,
5+
AWSSecretsManagerSettingsSource,
56
AzureKeyVaultSettingsSource,
67
CliExplicitFlag,
78
CliImplicitFlag,
@@ -27,6 +28,7 @@
2728

2829
__all__ = (
2930
'CLI_SUPPRESS',
31+
'AWSSecretsManagerSettingsSource',
3032
'AzureKeyVaultSettingsSource',
3133
'BaseSettings',
3234
'CliApp',

pydantic_settings/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
PydanticBaseSettingsSource,
88
get_subcommand,
99
)
10+
from .providers.aws import AWSSecretsManagerSettingsSource
1011
from .providers.azure import AzureKeyVaultSettingsSource
1112
from .providers.cli import (
1213
CLI_SUPPRESS,
@@ -30,6 +31,7 @@
3031
__all__ = [
3132
'CLI_SUPPRESS',
3233
'ENV_FILE_SENTINEL',
34+
'AWSSecretsManagerSettingsSource',
3335
'AzureKeyVaultSettingsSource',
3436
'CliExplicitFlag',
3537
'CliImplicitFlag',

pydantic_settings/sources/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Package containing individual source implementations."""
22

3+
from .aws import AWSSecretsManagerSettingsSource
34
from .azure import AzureKeyVaultSettingsSource
45
from .cli import (
56
CliExplicitFlag,
@@ -19,6 +20,7 @@
1920
from .yaml import YamlConfigSettingsSource
2021

2122
__all__ = [
23+
'AWSSecretsManagerSettingsSource',
2224
'AzureKeyVaultSettingsSource',
2325
'CliExplicitFlag',
2426
'CliImplicitFlag',
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations as _annotations # important for BaseSettings import to work
2+
3+
import json
4+
from collections.abc import Mapping
5+
from typing import TYPE_CHECKING, Optional
6+
7+
from .env import EnvSettingsSource
8+
9+
if TYPE_CHECKING:
10+
from pydantic_settings.main import BaseSettings
11+
12+
13+
boto3_client = None
14+
SecretsManagerClient = None
15+
16+
17+
def import_aws_secrets_manager() -> None:
18+
global boto3_client
19+
global SecretsManagerClient
20+
21+
try:
22+
from boto3 import client as boto3_client
23+
from mypy_boto3_secretsmanager.client import SecretsManagerClient
24+
except ImportError as e:
25+
raise ImportError(
26+
'AWS Secrets Manager dependencies are not installed, run `pip install pydantic-settings[aws-secrets-manager]`'
27+
) from e
28+
29+
30+
class AWSSecretsManagerSettingsSource(EnvSettingsSource):
31+
_secret_id: str
32+
_secretsmanager_client: SecretsManagerClient # type: ignore
33+
34+
def __init__(
35+
self,
36+
settings_cls: type[BaseSettings],
37+
secret_id: str,
38+
env_prefix: str | None = None,
39+
env_parse_none_str: str | None = None,
40+
env_parse_enums: bool | None = None,
41+
) -> None:
42+
import_aws_secrets_manager()
43+
self._secretsmanager_client = boto3_client('secretsmanager') # type: ignore
44+
self._secret_id = secret_id
45+
super().__init__(
46+
settings_cls,
47+
case_sensitive=True,
48+
env_prefix=env_prefix,
49+
env_nested_delimiter='--',
50+
env_ignore_empty=False,
51+
env_parse_none_str=env_parse_none_str,
52+
env_parse_enums=env_parse_enums,
53+
)
54+
55+
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
56+
response = self._secretsmanager_client.get_secret_value(SecretId=self._secret_id) # type: ignore
57+
58+
return json.loads(response['SecretString'])
59+
60+
def __repr__(self) -> str:
61+
return (
62+
f'{self.__class__.__name__}(secret_id={self._secret_id!r}, '
63+
f'env_nested_delimiter={self.env_nested_delimiter!r})'
64+
)
65+
66+
67+
__all__ = [
68+
'AWSSecretsManagerSettingsSource',
69+
]

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dynamic = ['version']
5050
yaml = ["pyyaml>=6.0.1"]
5151
toml = ["tomli>=2.0.1"]
5252
azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]
53+
aws-secrets-manager = ["boto3>=1.35.0", "boto3-stubs[secretsmanager]"]
5354

5455
[project.urls]
5556
Homepage = 'https://github.com/pydantic/pydantic-settings'
@@ -66,20 +67,23 @@ linting = [
6667
"pyyaml",
6768
"ruff",
6869
"types-pyyaml",
70+
"boto3-stubs[secretsmanager]",
6971
]
7072
testing = [
7173
"coverage[toml]",
7274
"pytest",
7375
"pytest-examples",
7476
"pytest-mock",
7577
"pytest-pretty",
78+
"moto[secretsmanager]",
7679
]
7780

7881
[tool.pytest.ini_options]
7982
testpaths = 'tests'
8083
filterwarnings = [
8184
'error',
8285
'ignore:This is a placeholder until pydantic-settings.*:UserWarning',
86+
'ignore::DeprecationWarning:botocore.*:',
8387
]
8488

8589
[tool.coverage.run]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Test pydantic_settings.AWSSecretsManagerSettingsSource.
3+
"""
4+
5+
import json
6+
import os
7+
8+
import pytest
9+
10+
try:
11+
import yaml
12+
from moto import mock_aws
13+
except ImportError:
14+
yaml = None
15+
mock_aws = None
16+
17+
from pydantic import BaseModel, Field
18+
19+
from pydantic_settings import (
20+
AWSSecretsManagerSettingsSource,
21+
BaseSettings,
22+
PydanticBaseSettingsSource,
23+
)
24+
from pydantic_settings.sources.providers.aws import import_aws_secrets_manager
25+
26+
try:
27+
aws_secrets_manager = True
28+
import_aws_secrets_manager()
29+
import boto3
30+
31+
os.environ['AWS_DEFAULT_REGION'] = os.environ.get('AWS_DEFAULT_REGION', 'us-east-1')
32+
except ImportError:
33+
aws_secrets_manager = False
34+
35+
36+
MODULE = 'pydantic_settings.sources'
37+
38+
if not yaml:
39+
pytest.skip('PyYAML is not installed', allow_module_level=True)
40+
41+
42+
@pytest.mark.skipif(not aws_secrets_manager, reason='pydantic-settings[aws-secrets-manager] is not installed')
43+
class TestAWSSecretsManagerSettingsSource:
44+
"""Test AWSSecretsManagerSettingsSource."""
45+
46+
@mock_aws
47+
def test___init__(self) -> None:
48+
"""Test __init__."""
49+
50+
class AWSSecretsManagerSettings(BaseSettings):
51+
"""AWSSecretsManager settings."""
52+
53+
client = boto3.client('secretsmanager')
54+
client.create_secret(Name='test-secret', SecretString='{}')
55+
56+
AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret')
57+
58+
@mock_aws
59+
def test___call__(self) -> None:
60+
"""Test __call__."""
61+
62+
class SqlServer(BaseModel):
63+
password: str = Field(..., alias='Password')
64+
65+
class AWSSecretsManagerSettings(BaseSettings):
66+
"""AWSSecretsManager settings."""
67+
68+
sql_server_user: str = Field(..., alias='SqlServerUser')
69+
sql_server: SqlServer = Field(..., alias='SqlServer')
70+
71+
secret_data = {'SqlServerUser': 'test-user', 'SqlServer--Password': 'test-password'}
72+
73+
client = boto3.client('secretsmanager')
74+
client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data))
75+
76+
obj = AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret')
77+
78+
settings = obj()
79+
80+
assert settings['SqlServerUser'] == 'test-user'
81+
assert settings['SqlServer']['Password'] == 'test-password'
82+
83+
@mock_aws
84+
def test_aws_secrets_manager_settings_source(self) -> None:
85+
"""Test AWSSecretsManagerSettingsSource."""
86+
87+
class SqlServer(BaseModel):
88+
password: str = Field(..., alias='Password')
89+
90+
class AWSSecretsManagerSettings(BaseSettings):
91+
"""AWSSecretsManager settings."""
92+
93+
sql_server_user: str = Field(..., alias='SqlServerUser')
94+
sql_server: SqlServer = Field(..., alias='SqlServer')
95+
96+
@classmethod
97+
def settings_customise_sources(
98+
cls,
99+
settings_cls: type[BaseSettings],
100+
init_settings: PydanticBaseSettingsSource,
101+
env_settings: PydanticBaseSettingsSource,
102+
dotenv_settings: PydanticBaseSettingsSource,
103+
file_secret_settings: PydanticBaseSettingsSource,
104+
) -> tuple[PydanticBaseSettingsSource, ...]:
105+
return (AWSSecretsManagerSettingsSource(settings_cls, 'test-secret'),)
106+
107+
secret_data = {'SqlServerUser': 'test-user', 'SqlServer--Password': 'test-password'}
108+
109+
client = boto3.client('secretsmanager')
110+
client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data))
111+
112+
settings = AWSSecretsManagerSettings() # type: ignore
113+
114+
assert settings.sql_server_user == 'test-user'
115+
assert settings.sql_server.password == 'test-password'

0 commit comments

Comments
 (0)