Skip to content

Commit 88c309d

Browse files
feat(parameters): add support for retrieving batch of secrets (#7058)
* Adding support for retrieve multiple secrets * Adding support for retrieve multiple secrets * Adding more coverage
1 parent d25238c commit 88c309d

File tree

8 files changed

+841
-7
lines changed

8 files changed

+841
-7
lines changed

aws_lambda_powertools/utilities/parameters/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .base import BaseProvider, clear_caches
77
from .dynamodb import DynamoDBProvider
88
from .exceptions import GetParameterError, TransformParameterError
9-
from .secrets import SecretsProvider, get_secret, set_secret
9+
from .secrets import SecretsProvider, get_secret, get_secrets_by_name, set_secret
1010
from .ssm import SSMProvider, get_parameter, get_parameters, get_parameters_by_name, set_parameter
1111

1212
__all__ = [
@@ -23,6 +23,7 @@
2323
"get_parameters",
2424
"get_parameters_by_name",
2525
"get_secret",
26+
"get_secrets_by_name",
2627
"set_secret",
2728
"clear_caches",
2829
]

aws_lambda_powertools/utilities/parameters/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class GetParameterError(Exception):
77
"""When a provider raises an exception on parameter retrieval"""
88

99

10+
class GetSecretError(Exception):
11+
"""When a provider raises an exception on secret retrieval"""
12+
13+
1014
class TransformParameterError(Exception):
1115
"""When a provider fails to transform a parameter value"""
1216

aws_lambda_powertools/utilities/parameters/secrets.py

Lines changed: 237 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@
88
import logging
99
import os
1010
import warnings
11-
from typing import TYPE_CHECKING, Literal, overload
11+
from typing import TYPE_CHECKING, Any, Literal, overload
1212

1313
import boto3
1414

1515
from aws_lambda_powertools.shared import constants
1616
from aws_lambda_powertools.shared.functions import resolve_max_age
1717
from aws_lambda_powertools.shared.json_encoder import Encoder
18-
from aws_lambda_powertools.utilities.parameters.base import BaseProvider
18+
from aws_lambda_powertools.utilities.parameters.base import BaseProvider, transform_value
1919
from aws_lambda_powertools.utilities.parameters.constants import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS
20-
from aws_lambda_powertools.utilities.parameters.exceptions import SetSecretError
20+
from aws_lambda_powertools.utilities.parameters.exceptions import (
21+
GetSecretError,
22+
SetSecretError,
23+
TransformParameterError,
24+
)
2125
from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning
2226

2327
if TYPE_CHECKING:
@@ -126,11 +130,159 @@ def _get(self, name: str, **sdk_options) -> str | bytes:
126130

127131
return secret_value["SecretBinary"]
128132

129-
def _get_multiple(self, path: str, **sdk_options) -> dict[str, str]:
133+
def _get_multiple(self, names: list[str], **sdk_options) -> dict[str, Any]: # type: ignore[override]
130134
"""
131-
Retrieving multiple parameter values is not supported with AWS Secrets Manager
135+
Retrieve multiple secrets using AWS Secrets Manager batch_get_secret_value API
136+
137+
Parameters
138+
----------
139+
names: list[str]
140+
List of secret names to retrieve
141+
sdk_options: dict, optional
142+
Additional options passed to batch_get_secret_value API call
143+
144+
Returns
145+
-------
146+
dict[str, str]
147+
Dictionary mapping secret names to their values
148+
149+
Raises
150+
------
151+
GetParameterError
152+
When the parameter provider fails to retrieve secrets
132153
"""
133-
raise NotImplementedError()
154+
155+
# Merge filters: combine names with any additional filters from sdk_options
156+
filters = sdk_options.get("Filters", [])
157+
name_filter = {"Key": "name", "Values": names}
158+
159+
# Add name filter to existing filters
160+
filters.append(name_filter)
161+
sdk_options["Filters"] = filters
162+
163+
# Remove SecretIdList if present to avoid conflicts
164+
sdk_options.pop("SecretIdList", None)
165+
166+
secrets: dict[str, Any] = {}
167+
next_token = None
168+
169+
# Handle pagination automatically
170+
while True:
171+
if next_token:
172+
sdk_options["NextToken"] = next_token
173+
elif "NextToken" in sdk_options:
174+
# Remove NextToken from first call if it was passed
175+
sdk_options.pop("NextToken") # pragma: no cover
176+
177+
try:
178+
response = self.client.batch_get_secret_value(**sdk_options)
179+
except Exception as exc:
180+
raise GetSecretError(f"Failed to retrieve secrets: {str(exc)}") from exc
181+
182+
# Process successful secrets
183+
for secret in response.get("SecretValues", []):
184+
secret_name = secret["Name"]
185+
186+
# Extract secret value (SecretString or SecretBinary)
187+
if "SecretString" in secret:
188+
secrets[secret_name] = secret["SecretString"]
189+
elif "SecretBinary" in secret:
190+
secrets[secret_name] = secret["SecretBinary"]
191+
192+
# Check if there are more results
193+
next_token = response.get("NextToken")
194+
if not next_token:
195+
break
196+
197+
# If no secrets were found, raise an error
198+
if not secrets:
199+
raise GetSecretError(f"No secrets found matching the provided names: {names}")
200+
201+
return secrets
202+
203+
def get_multiple( # type: ignore[override]
204+
self,
205+
names: list[str],
206+
max_age: int | None = None,
207+
transform: TransformOptions = None,
208+
raise_on_transform_error: bool = False,
209+
force_fetch: bool = False,
210+
**sdk_options,
211+
) -> dict[str, Any]:
212+
"""
213+
Retrieve multiple secrets by name from AWS Secrets Manager
214+
215+
Parameters
216+
----------
217+
names: list[str]
218+
List of secret names to retrieve
219+
max_age: int, optional
220+
Maximum age of the cached value
221+
transform: str, optional
222+
Optional transformation of the parameter value. Supported values
223+
are "json" for JSON strings and "binary" for base 64 encoded values.
224+
raise_on_transform_error: bool, optional
225+
Raises an exception if any transform fails, otherwise this will
226+
return a None value for each transform that failed
227+
force_fetch: bool, optional
228+
Force update even before a cached item has expired, defaults to False
229+
sdk_options: dict, optional
230+
Arguments that will be passed directly to the underlying API call
231+
232+
Returns
233+
-------
234+
dict[str, str | bytes | dict]
235+
Dictionary mapping secret names to their values
236+
237+
Raises
238+
------
239+
GetParameterError
240+
When the parameter provider fails to retrieve secrets
241+
TransformParameterError
242+
When the parameter provider fails to transform a secret value
243+
"""
244+
if not names:
245+
raise GetSecretError("You must provide at least one secret name")
246+
247+
# Create a unique cache key for this batch of secrets
248+
# Use sorted names to ensure consistent caching regardless of order
249+
cache_key_name = "|".join(sorted(names))
250+
key = self._build_cache_key(name=cache_key_name, transform=transform, is_nested=True)
251+
252+
# If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS
253+
max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age)
254+
255+
if not force_fetch and self.has_not_expired_in_cache(key):
256+
cached_values = self.fetch_from_cache(key)
257+
# Return only the requested secrets from cache (in case cache has more)
258+
return {name: cached_values[name] for name in names if name in cached_values}
259+
260+
try:
261+
values = self._get_multiple(names, **sdk_options)
262+
except Exception as exc:
263+
raise GetSecretError(str(exc)) from exc
264+
265+
if transform:
266+
# Transform each secret value
267+
transformed_values = {}
268+
for name, value in values.items():
269+
try:
270+
transformed_values[name] = transform_value(
271+
key=name,
272+
value=value,
273+
transform=transform,
274+
raise_on_transform_error=raise_on_transform_error,
275+
)
276+
except TransformParameterError:
277+
if raise_on_transform_error:
278+
raise
279+
transformed_values[name] = None # pragma: no cover
280+
values = transformed_values
281+
282+
# Cache the results
283+
self.add_to_cache(key=key, value=values, max_age=max_age)
284+
285+
return values
134286

135287
def _create_secret(self, name: str, **sdk_options) -> CreateSecretResponseTypeDef:
136288
"""
@@ -369,6 +521,85 @@ def get_secret(
369521
)
370522

371523

524+
def get_secrets_by_name(
525+
names: list[str],
526+
transform: TransformOptions = None,
527+
force_fetch: bool = False,
528+
max_age: int | None = None,
529+
**sdk_options,
530+
) -> dict[str, str | bytes | dict]:
531+
"""
532+
Retrieve multiple secrets by name from AWS Secrets Manager
533+
534+
Parameters
535+
----------
536+
names: list[str]
537+
List of secret names to retrieve
538+
transform: str, optional
539+
Transforms the content from a JSON object ('json') or base64 binary string ('binary')
540+
force_fetch: bool, optional
541+
Force update even before a cached item has expired, defaults to False
542+
max_age: int, optional
543+
Maximum age of the cached value
544+
sdk_options: dict, optional
545+
Dictionary of options that will be passed to the batch_get_secret_value call
546+
547+
Raises
548+
------
549+
GetParameterError
550+
When the parameter provider fails to retrieve secrets
551+
TransformParameterError
552+
When the parameter provider fails to transform a secret value
553+
554+
Returns
555+
-------
556+
dict[str, str | bytes | dict]
557+
Dictionary mapping secret names to their values
558+
559+
Example
560+
-------
561+
**Retrieves multiple secrets**
562+
563+
>>> from aws_lambda_powertools.utilities.parameters import get_secrets_by_name
564+
>>>
565+
>>> secrets = get_secrets_by_name(["db-password", "api-key", "jwt-secret"])
566+
>>> print(secrets["db-password"])
567+
568+
**Retrieves multiple secrets with JSON transformation**
569+
570+
>>> from aws_lambda_powertools.utilities.parameters import get_secrets_by_name
571+
>>>
572+
>>> secrets = get_secrets_by_name(["config", "settings"], transform="json")
573+
>>> print(secrets["config"]["database_url"])
574+
575+
**Retrieves multiple secrets with additional filters**
576+
577+
>>> from aws_lambda_powertools.utilities.parameters import get_secrets_by_name
578+
>>>
579+
>>> secrets = get_secrets_by_name(
580+
... names=["app-secret"],
581+
... Filters=[{"Key": "primary-region", "Values": ["us-east-1"]}]
582+
... )
583+
"""
584+
if not names:
585+
raise GetSecretError("You must provide at least one secret name")
586+
587+
# If max_age is not set, resolve it from the environment variable, defaulting to DEFAULT_MAX_AGE_SECS
588+
max_age = resolve_max_age(env=os.getenv(constants.PARAMETERS_MAX_AGE_ENV, DEFAULT_MAX_AGE_SECS), choice=max_age)
589+
590+
# Only create the provider if this function is called at least once
591+
if "secrets" not in DEFAULT_PROVIDERS:
592+
DEFAULT_PROVIDERS["secrets"] = SecretsProvider()
593+
594+
return DEFAULT_PROVIDERS["secrets"].get_multiple(
595+
names=names,
596+
max_age=max_age,
597+
transform=transform,
598+
force_fetch=force_fetch,
599+
**sdk_options,
600+
)
601+
602+
372603
def set_secret(
373604
name: str,
374605
value: str | bytes,

docs/utilities/parameters.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This utility requires additional permissions to work as expected.
3636
| SSM | If using **`decrypt=True`** | You must add an additional permission **`kms:Decrypt`** |
3737
| Secrets | **`get_secret`**, **`SecretsProvider.get`** | **`secretsmanager:GetSecretValue`** |
3838
| Secrets | **`set_secret`**, **`SecretsProvider.set`** | **`secretsmanager:PutSecretValue`** and **`secretsmanager:CreateSecret`** (if creating secrets) |
39+
| Secrets | **`get_secrets_by_name`**, **`SecretsProvider.get_multiple`** | **`secretsmanager:BatchGetSecretValue`**, **`secretsmanager:GetSecretValue`** and **`secretsmanager:ListSecrets`** |
3940
| DynamoDB | **`DynamoDBProvider.get`** | **`dynamodb:GetItem`** |
4041
| DynamoDB | **`DynamoDBProvider.get_multiple`** | **`dynamodb:Query`** |
4142
| AppConfig | **`get_app_config`**, **`AppConfigProvider.get_app_config`** | **`appconfig:GetLatestConfiguration`** and **`appconfig:StartConfigurationSession`** |
@@ -111,6 +112,30 @@ You can fetch secrets stored in Secrets Manager using `get_secret`.
111112
--8<-- "examples/parameters/src/getting_started_secret.py"
112113
```
113114

115+
### Fetching multiple secrets
116+
117+
You can fetch multiple secrets from Secrets Manager in a single API call using `get_secrets_by_name`. This reduces the number of API calls and improves performance when you need to retrieve several secrets at once.
118+
119+
???+ info "Batch retrieval benefits"
120+
- **Performance**: Retrieve up to 20 secrets in one API call
121+
- **Cost optimization**: Fewer API calls reduce AWS costs
122+
- **Error resilience**: Partial failures don't break the entire operation
123+
- **Advanced filtering**: Use additional filters beyond secret names
124+
125+
=== "getting_started_batch_secrets.py"
126+
```python hl_lines="1 7"
127+
--8<-- "examples/parameters/src/getting_started_batch_secrets.py"
128+
```
129+
130+
#### Advanced filtering
131+
132+
You can combine secret name filtering with additional AWS Secrets Manager filters for more precise results:
133+
134+
=== "batch_secrets_with_filters.py"
135+
```python hl_lines="2 10-16"
136+
--8<-- "examples/parameters/src/batch_secrets_with_filters.py"
137+
```
138+
114139
### Setting secrets
115140

116141
You can set secrets stored in Secrets Manager using `set_secret`.
@@ -251,6 +276,11 @@ You can create `SecureString` parameters, which are parameters that have a plain
251276
--8<-- "examples/parameters/src/builtin_provider_secret.py"
252277
```
253278

279+
=== "batch_secrets_provider.py"
280+
```python hl_lines="2-12"
281+
--8<-- "examples/parameters/src/batch_secrets_provider.py"
282+
```
283+
254284
#### DynamoDBProvider
255285

256286
The DynamoDB Provider does not have any high-level functions, as it needs to know the name of the DynamoDB table containing the parameters.
@@ -445,7 +475,9 @@ Here is the mapping between this utility's functions and methods and the underly
445475
| SSM Parameter Store | `SSMProvider.get` | `ssm` | [get_parameter](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter){target="_blank"} |
446476
| SSM Parameter Store | `SSMProvider.get_multiple` | `ssm` | [get_parameters_by_path](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameters_by_path){target="_blank"} |
447477
| Secrets Manager | `get_secret` | `secretsmanager` | [get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.get_secret_value){target="_blank"} |
478+
| Secrets Manager | `get_secrets_by_name` | `secretsmanager` | [batch_get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.batch_get_secret_value){target="_blank"} |
448479
| Secrets Manager | `SecretsProvider.get` | `secretsmanager` | [get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.get_secret_value){target="_blank"} |
480+
| Secrets Manager | `SecretsProvider.get_multiple` | `secretsmanager` | [batch_get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.batch_get_secret_value){target="_blank"} |
449481
| DynamoDB | `DynamoDBProvider.get` | `dynamodb` | ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table){target="_blank"}) |
450482
| DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb` | ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table){target="_blank"}) |
451483
| App Config | `get_app_config` | `appconfigdata` | [start_configuration_session](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/appconfigdata.html#AppConfigData.Client.start_configuration_session){target="_blank"} and [get_latest_configuration](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/appconfigdata.html#AppConfigData.Client.get_latest_configuration){target="_blank"} |
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.utilities.parameters import SecretsProvider
3+
from aws_lambda_powertools.utilities.typing import LambdaContext
4+
5+
logger = Logger()
6+
# Create provider instance for more control
7+
secrets_provider = SecretsProvider()
8+
9+
10+
def lambda_handler(event, context: LambdaContext):
11+
# Retrieve secrets with custom settings
12+
secrets = secrets_provider.get_multiple(
13+
names=["service/auth-token", "service/encryption-key"],
14+
max_age=600, # Cache for 10 minutes
15+
transform="json", # Parse JSON secrets
16+
raise_on_transform_error=False, # Don't fail on transform errors
17+
)
18+
19+
# Handle potential transform failures
20+
auth_token = secrets.get("service/auth-token")
21+
encryption_key = secrets.get("service/encryption-key")
22+
23+
if auth_token is None:
24+
logger.info("Warning: auth-token failed to parse as JSON")
25+
if encryption_key is None:
26+
logger.info("Warning: encryption-key failed to parse as JSON")
27+
28+
return {
29+
"statusCode": 200,
30+
"body": f"Retrieved {len([s for s in secrets.values() if s is not None])} valid secrets",
31+
}

0 commit comments

Comments
 (0)