Skip to content

Commit 3b6828b

Browse files
committed
Adds GCP Secret Manager source with doc and tests
1 parent 01407c0 commit 3b6828b

File tree

8 files changed

+716
-1
lines changed

8 files changed

+716
-1
lines changed

docs/index.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,6 +1833,125 @@ class AzureKeyVaultSettings(BaseSettings):
18331833
)
18341834
```
18351835

1836+
## Google Cloud Secret Manager
1837+
1838+
Google Cloud Secret Manager allows you to store, manage, and access sensitive information as secrets in Google Cloud Platform. This integration lets you retrieve secrets directly from GCP Secret Manager for use in your Pydantic settings.
1839+
1840+
### Installation
1841+
1842+
The Google Cloud Secret Manager integration requires additional dependencies:
1843+
1844+
```bash
1845+
pip install "pydantic-settings[gcp-secret-manager]"
1846+
```
1847+
1848+
### Basic Usage
1849+
1850+
To use Google Cloud Secret Manager, you need to:
1851+
1852+
1. Create a `GoogleSecretManagerSettingsSource` with your project ID and credentials
1853+
2. Add this source to your settings customization pipeline
1854+
1855+
The `GoogleSecretManagerSettingsSource` uses the secret name as the field name (case-sensitive by default).
1856+
1857+
```py
1858+
from google.auth.credentials import Credentials
1859+
from pydantic import BaseModel
1860+
1861+
from pydantic_settings import (
1862+
BaseSettings,
1863+
GoogleSecretManagerSettingsSource,
1864+
PydanticBaseSettingsSource,
1865+
)
1866+
1867+
1868+
class SubModel(BaseModel):
1869+
password: str
1870+
1871+
1872+
class GcpSettings(BaseSettings):
1873+
api_key: str
1874+
database: SubModel
1875+
1876+
@classmethod
1877+
def settings_customise_sources(
1878+
cls,
1879+
settings_cls: type[BaseSettings],
1880+
init_settings: PydanticBaseSettingsSource,
1881+
env_settings: PydanticBaseSettingsSource,
1882+
dotenv_settings: PydanticBaseSettingsSource,
1883+
file_secret_settings: PydanticBaseSettingsSource,
1884+
) -> tuple[PydanticBaseSettingsSource, ...]:
1885+
# Create the GCP Secret Manager settings source
1886+
gcp_settings = GoogleSecretManagerSettingsSource(
1887+
settings_cls,
1888+
# If not provided, will use google.auth.default()
1889+
# to get credentials from the environemnt
1890+
# credentials=your_credentials,
1891+
# If not provided, will use google.auth.default()
1892+
# to get project_id from the environemnt
1893+
project_id="your-gcp-project-id",
1894+
)
1895+
1896+
return (
1897+
init_settings,
1898+
env_settings,
1899+
dotenv_settings,
1900+
file_secret_settings,
1901+
gcp_settings,
1902+
)
1903+
```
1904+
1905+
### Authentication
1906+
1907+
The `GoogleSecretManagerSettingsSource` supports several authentication methods:
1908+
1909+
1. **Default credentials** - If you don't provide credentials or project ID, it will use `google.auth.default()` to obtain them. This works with:
1910+
- Service account credentials from `GOOGLE_APPLICATION_CREDENTIALS` environment variable
1911+
- User credentials from `gcloud auth application-default login`
1912+
- Compute Engine, GKE, Cloud Run, or Cloud Functions default service accounts
1913+
1914+
2. **Explicit credentials** - You can provide credentials directly:
1915+
```py
1916+
from google.oauth2 import service_account
1917+
1918+
credentials = service_account.Credentials.from_service_account_file(
1919+
"path/to/service-account.json"
1920+
)
1921+
1922+
gcp_settings = GoogleSecretManagerSettingsSource(
1923+
settings_cls,
1924+
credentials=credentials,
1925+
project_id="your-gcp-project-id",
1926+
)
1927+
```
1928+
1929+
### Nested Models
1930+
1931+
For nested models, Secret Manager supports the `env_nested_delimiter` setting as long as it complies with the [naming rules](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets#create-a-secret). For example, if you have:
1932+
1933+
```py
1934+
class Database(BaseModel):
1935+
password: str
1936+
user: str
1937+
1938+
class Settings(BaseSettings):
1939+
database: Database
1940+
1941+
model_config = SettingsConfigDict(env_nested_delimiter='__')
1942+
```
1943+
1944+
You would create secrets named `database__password` and `database__user` in Secret Manager.
1945+
1946+
### Important Notes
1947+
1948+
1. **Case Sensitivity**: By default, secret names are case-sensitive.
1949+
2. **Secret Naming**: Create secrets in Google Secret Manager with names that match your field names (including any prefix). According the [Secret Manager documentation](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets#create-a-secret), a secret name can contain uppercase and lowercase letters, numerals, hyphens, and underscores. The maximum allowed length for a name is 255 characters.
1950+
3. **Secret Versions**: The integration uses the "latest" version of secrets by default.
1951+
1952+
For more details on creating and managing secrets in Google Cloud Secret Manager, see the [official Google Cloud documentation](https://cloud.google.com/secret-manager/docs).
1953+
1954+
18361955
## Other settings source
18371956

18381957
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
@@ -13,6 +13,7 @@
1313
DotEnvSettingsSource,
1414
EnvSettingsSource,
1515
ForceDecode,
16+
GoogleSecretManagerSettingsSource,
1617
InitSettingsSource,
1718
JsonConfigSettingsSource,
1819
NoDecode,
@@ -40,6 +41,7 @@
4041
'DotEnvSettingsSource',
4142
'EnvSettingsSource',
4243
'ForceDecode',
44+
'GoogleSecretManagerSettingsSource',
4345
'InitSettingsSource',
4446
'JsonConfigSettingsSource',
4547
'NoDecode',

pydantic_settings/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2222
from .providers.env import EnvSettingsSource
23+
from .providers.gcp import GoogleSecretManagerSettingsSource
2324
from .providers.json import JsonConfigSettingsSource
2425
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2526
from .providers.secrets import SecretsSettingsSource
@@ -43,6 +44,7 @@
4344
'DotenvType',
4445
'EnvSettingsSource',
4546
'ForceDecode',
47+
'GoogleSecretManagerSettingsSource',
4648
'InitSettingsSource',
4749
'JsonConfigSettingsSource',
4850
'NoDecode',

pydantic_settings/sources/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from .dotenv import DotEnvSettingsSource
1414
from .env import EnvSettingsSource
15+
from .gcp import GoogleSecretManagerSettingsSource
1516
from .json import JsonConfigSettingsSource
1617
from .pyproject import PyprojectTomlConfigSettingsSource
1718
from .secrets import SecretsSettingsSource
@@ -29,6 +30,7 @@
2930
'CliSuppress',
3031
'DotEnvSettingsSource',
3132
'EnvSettingsSource',
33+
'GoogleSecretManagerSettingsSource',
3234
'JsonConfigSettingsSource',
3335
'PyprojectTomlConfigSettingsSource',
3436
'SecretsSettingsSource',
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations as _annotations
2+
3+
from collections.abc import Iterator, Mapping
4+
from functools import cached_property
5+
from typing import TYPE_CHECKING, Optional
6+
7+
from .env import EnvSettingsSource
8+
9+
if TYPE_CHECKING:
10+
from google.auth import default as google_auth_default
11+
from google.auth.credentials import Credentials
12+
from google.cloud.secretmanager import SecretManagerServiceClient
13+
14+
from pydantic_settings.main import BaseSettings
15+
else:
16+
Credentials = None
17+
SecretManagerServiceClient = None
18+
google_auth_default = None
19+
20+
21+
def import_gcp_secret_manager() -> None:
22+
global Credentials
23+
global SecretManagerServiceClient
24+
global google_auth_default
25+
26+
try:
27+
from google.auth import default as google_auth_default
28+
from google.auth.credentials import Credentials
29+
from google.cloud.secretmanager import SecretManagerServiceClient
30+
except ImportError as e:
31+
raise ImportError(
32+
'GCP Secret Namager dependencies are not installed, run `pip install pydantic-settings[gcp-secret-manager]`'
33+
) from e
34+
35+
36+
class GoogleSecretManagerMapping(Mapping[str, Optional[str]]):
37+
_loaded_secrets: dict[str, str | None]
38+
_secret_client: SecretManagerServiceClient
39+
40+
def __init__(self, secret_client: SecretManagerServiceClient, project_id: str) -> None:
41+
self._loaded_secrets = {}
42+
self._secret_client = secret_client
43+
self._project_id = project_id
44+
45+
@property
46+
def _gcp_project_path(self) -> str:
47+
return self._secret_client.common_project_path(self._project_id)
48+
49+
@cached_property
50+
def _secret_names(self) -> list[str]:
51+
return [
52+
self._secret_client.parse_secret_path(secret.name).get('secret', '')
53+
for secret in self._secret_client.list_secrets(parent=self._gcp_project_path)
54+
]
55+
56+
def _secret_version_path(self, key: str, version: str = 'latest') -> str:
57+
return self._secret_client.secret_version_path(self._project_id, key, version)
58+
59+
def __getitem__(self, key: str) -> str | None:
60+
if key not in self._loaded_secrets:
61+
# If we know the key isn't available in secret manager, raise a key error
62+
if key not in self._secret_names:
63+
raise KeyError(key)
64+
65+
try:
66+
self._loaded_secrets[key] = self._secret_client.access_secret_version(
67+
name=self._secret_version_path(key)
68+
).payload.data.decode('UTF-8')
69+
except Exception:
70+
raise KeyError(key)
71+
72+
return self._loaded_secrets[key]
73+
74+
def __len__(self) -> int:
75+
return len(self._secret_names)
76+
77+
def __iter__(self) -> Iterator[str]:
78+
return iter(self._secret_names)
79+
80+
81+
class GoogleSecretManagerSettingsSource(EnvSettingsSource):
82+
_credentials: Credentials
83+
_secret_client: SecretManagerServiceClient
84+
_project_id: str
85+
86+
def __init__(
87+
self,
88+
settings_cls: type[BaseSettings],
89+
credentials: Credentials | None = None,
90+
project_id: str | None = None,
91+
env_prefix: str | None = None,
92+
env_parse_none_str: str | None = None,
93+
env_parse_enums: bool | None = None,
94+
secret_client: SecretManagerServiceClient | None = None,
95+
) -> None:
96+
# Import Google Packages if they haven't already been imported
97+
if SecretManagerServiceClient is None or Credentials is None or google_auth_default is None:
98+
import_gcp_secret_manager()
99+
100+
# If credentials or project_id are not passed, then
101+
# try to get them from the default function
102+
if not credentials or not project_id:
103+
_creds, _project_id = google_auth_default() # type: ignore[no-untyped-call]
104+
105+
# Set the credentials and/or project id if they weren't specified
106+
if credentials is None:
107+
credentials = _creds
108+
109+
if project_id is None:
110+
if isinstance(_project_id, str):
111+
project_id = _project_id
112+
else:
113+
raise AttributeError(
114+
'project_id is required to be specified either as an argument or from the google.auth.default. See https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default'
115+
)
116+
117+
self._credentials: Credentials = credentials
118+
self._project_id: str = project_id
119+
120+
if secret_client:
121+
self._secret_client = secret_client
122+
else:
123+
self._secret_client = SecretManagerServiceClient(credentials=self._credentials)
124+
125+
super().__init__(
126+
settings_cls,
127+
case_sensitive=True,
128+
env_prefix=env_prefix,
129+
env_ignore_empty=False,
130+
env_parse_none_str=env_parse_none_str,
131+
env_parse_enums=env_parse_enums,
132+
)
133+
134+
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
135+
return GoogleSecretManagerMapping(self._secret_client, project_id=self._project_id)
136+
137+
def __repr__(self) -> str:
138+
return f'{self.__class__.__name__}(project_id={self._project_id!r}, env_nested_delimiter={self.env_nested_delimiter!r})'
139+
140+
141+
__all__ = ['GoogleSecretManagerSettingsSource', 'GoogleSecretManagerMapping']

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ 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+
gcp-secret-manager = [
54+
"google-cloud-secret-manager>=2.23.1",
55+
]
5356

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

0 commit comments

Comments
 (0)