Skip to content

Commit fde79e8

Browse files
Add support for GCP Secret Manager (#567)
Co-authored-by: hyperlint-ai[bot] <154288675+hyperlint-ai[bot]@users.noreply.github.com>
1 parent d54d146 commit fde79e8

File tree

8 files changed

+862
-212
lines changed

8 files changed

+862
-212
lines changed

docs/index.md

+93
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,99 @@ class AzureKeyVaultSettings(BaseSettings):
18881888
)
18891889
```
18901890

1891+
## Google Cloud Secret Manager
1892+
1893+
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.
1894+
1895+
### Installation
1896+
1897+
The Google Cloud Secret Manager integration requires additional dependencies:
1898+
1899+
```bash
1900+
pip install "pydantic-settings[gcp-secret-manager]"
1901+
```
1902+
1903+
### Basic Usage
1904+
1905+
To use Google Cloud Secret Manager, you need to:
1906+
1907+
1. Create a `GoogleSecretManagerSettingsSource`. (See [GCP Authentication](#gcp-authentication) for authentication options.)
1908+
2. Add this source to your settings customization pipeline
1909+
1910+
```py
1911+
from pydantic import BaseModel
1912+
1913+
from pydantic_settings import (
1914+
BaseSettings,
1915+
GoogleSecretManagerSettingsSource,
1916+
PydanticBaseSettingsSource,
1917+
SettingsConfigDict,
1918+
)
1919+
1920+
1921+
class Database(BaseModel):
1922+
password: str
1923+
user: str
1924+
1925+
1926+
class Settings(BaseSettings):
1927+
database: Database
1928+
1929+
model_config = SettingsConfigDict(env_nested_delimiter='__')
1930+
1931+
@classmethod
1932+
def settings_customise_sources(
1933+
cls,
1934+
settings_cls: type[BaseSettings],
1935+
init_settings: PydanticBaseSettingsSource,
1936+
env_settings: PydanticBaseSettingsSource,
1937+
dotenv_settings: PydanticBaseSettingsSource,
1938+
file_secret_settings: PydanticBaseSettingsSource,
1939+
) -> tuple[PydanticBaseSettingsSource, ...]:
1940+
# Create the GCP Secret Manager settings source
1941+
gcp_settings = GoogleSecretManagerSettingsSource(
1942+
settings_cls,
1943+
# If not provided, will use google.auth.default()
1944+
# to get credentials from the environemnt
1945+
# credentials=your_credentials,
1946+
# If not provided, will use google.auth.default()
1947+
# to get project_id from the environemnt
1948+
project_id='your-gcp-project-id',
1949+
)
1950+
1951+
return (
1952+
init_settings,
1953+
env_settings,
1954+
dotenv_settings,
1955+
file_secret_settings,
1956+
gcp_settings,
1957+
)
1958+
```
1959+
1960+
### GCP Authentication
1961+
1962+
The `GoogleSecretManagerSettingsSource` supports several authentication methods:
1963+
1964+
1. **Default credentials** - If you don't provide credentials or project ID, it will use [`google.auth.default()`](https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default) to obtain them. This works with:
1965+
1966+
- Service account credentials from `GOOGLE_APPLICATION_CREDENTIALS` environment variable
1967+
- User credentials from `gcloud auth application-default login`
1968+
- Compute Engine, GKE, Cloud Run, or Cloud Functions default service accounts
1969+
1970+
2. **Explicit credentials** - You can also provide `credentials` directly. e.g. `sa_credentials = google.oauth2.service_account.Credentials.from_service_account_file('path/to/service-account.json')` and then `GoogleSecretManagerSettingsSource(credentials=sa_credentials)`
1971+
1972+
### Nested Models
1973+
1974+
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). In the example above, you would create secrets named `database__password` and `database__user` in Secret Manager.
1975+
1976+
### Important Notes
1977+
1978+
1. **Case Sensitivity**: By default, secret names are case-sensitive.
1979+
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.
1980+
3. **Secret Versions**: The GoogleSecretManagerSettingsSource uses the "latest" version of secrets.
1981+
1982+
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).
1983+
18911984
## Other settings source
18921985

18931986
Other settings sources are available for common configuration files:

pydantic_settings/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
DotEnvSettingsSource,
1515
EnvSettingsSource,
1616
ForceDecode,
17+
GoogleSecretManagerSettingsSource,
1718
InitSettingsSource,
1819
JsonConfigSettingsSource,
1920
NoDecode,
@@ -42,6 +43,7 @@
4243
'DotEnvSettingsSource',
4344
'EnvSettingsSource',
4445
'ForceDecode',
46+
'GoogleSecretManagerSettingsSource',
4547
'InitSettingsSource',
4648
'JsonConfigSettingsSource',
4749
'NoDecode',

pydantic_settings/sources/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2323
from .providers.env import EnvSettingsSource
24+
from .providers.gcp import GoogleSecretManagerSettingsSource
2425
from .providers.json import JsonConfigSettingsSource
2526
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2627
from .providers.secrets import SecretsSettingsSource
@@ -45,6 +46,7 @@
4546
'DotenvType',
4647
'EnvSettingsSource',
4748
'ForceDecode',
49+
'GoogleSecretManagerSettingsSource',
4850
'InitSettingsSource',
4951
'JsonConfigSettingsSource',
5052
'NoDecode',

pydantic_settings/sources/providers/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from .dotenv import DotEnvSettingsSource
1515
from .env import EnvSettingsSource
16+
from .gcp import GoogleSecretManagerSettingsSource
1617
from .json import JsonConfigSettingsSource
1718
from .pyproject import PyprojectTomlConfigSettingsSource
1819
from .secrets import SecretsSettingsSource
@@ -31,6 +32,7 @@
3132
'CliSuppress',
3233
'DotEnvSettingsSource',
3334
'EnvSettingsSource',
35+
'GoogleSecretManagerSettingsSource',
3436
'JsonConfigSettingsSource',
3537
'PyprojectTomlConfigSettingsSource',
3638
'SecretsSettingsSource',
+141
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

+3
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ 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"]
5353
aws-secrets-manager = ["boto3>=1.35.0", "boto3-stubs[secretsmanager]"]
54+
gcp-secret-manager = [
55+
"google-cloud-secret-manager>=2.23.1",
56+
]
5457

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

0 commit comments

Comments
 (0)