Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
- AWS Secrets Manager (`awssm`) provider: batch fetching via `BatchGetSecretValue` API,
reducing N sequential API calls to ceil(N/20) batched calls. For 30 secrets this means
2 API calls instead of 30. **Note:** requires the `secretsmanager:BatchGetSecretValue`
IAM permission in addition to existing permissions.

## [0.8.1] - 2026-03-15

### Added
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/providers/awssm.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ AWS Secrets Manager uses the standard AWS SDK credential chain:
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:BatchGetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:PutSecretValue"
],
Expand All @@ -89,6 +90,10 @@ AWS Secrets Manager uses the standard AWS SDK credential chain:
}
```

:::note
The `BatchGetSecretValue` permission is required for batch fetching, which is used automatically during `check` and `run` commands to reduce API calls. If your IAM policy was created before this feature, you may need to add this permission.
:::

### CI/CD

```bash
Expand Down
156 changes: 156 additions & 0 deletions secretspec/src/provider/awssm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ use crate::{Result, SecretSpecError};
use aws_sdk_secretsmanager::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use url::Url;

/// Maximum number of secrets per BatchGetSecretValue API call.
const AWS_BATCH_GET_MAX_SECRETS: usize = 20;

/// Configuration for the AWS Secrets Manager provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AwssmConfig {
Expand Down Expand Up @@ -198,6 +202,77 @@ impl AwssmProvider {
}
}

/// Builds the full AWS secret names and a reverse map back to the original keys.
fn build_batch_request_names(
project: &str,
keys: &[&str],
profile: &str,
) -> Result<(Vec<String>, HashMap<String, String>)> {
let mut secret_names = Vec::with_capacity(keys.len());
let mut name_to_key = HashMap::with_capacity(keys.len());
for key in keys {
let name = Self::format_secret_name(project, profile, key)?;
name_to_key.insert(name.clone(), key.to_string());
secret_names.push(name);
}
Ok((secret_names, name_to_key))
}

/// Fetches multiple secrets in batches of 20 using the BatchGetSecretValue API.
async fn get_batch_async(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
if keys.is_empty() {
return Ok(HashMap::new());
}

let client = self.create_client().await?;
let (secret_names, name_to_key) = Self::build_batch_request_names(project, keys, profile)?;
let mut results = HashMap::new();

for chunk in secret_names.chunks(AWS_BATCH_GET_MAX_SECRETS) {
let mut request = client.batch_get_secret_value();
for name in chunk {
request = request.secret_id_list(name.clone());
}

let response = request.send().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"BatchGetSecretValue failed: {}",
e.into_service_error()
))
})?;

// Process successful values
for secret in response.secret_values() {
if let (Some(name), Some(value)) = (secret.name(), secret.secret_string())
&& let Some(key) = name_to_key.get(name)
{
results.insert(key.clone(), SecretString::new(value.to_string().into()));
}
}

// Handle per-secret errors
for error in response.errors() {
let error_code = error.error_code().unwrap_or("Unknown");
if error_code != "ResourceNotFoundException" {
let secret_id = error.secret_id().unwrap_or("unknown");
let message = error.message().unwrap_or("no message");
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to get secret '{}': {} - {}",
secret_id, error_code, message
)));
}
// ResourceNotFoundException: secret not present, omit from results
}
}

Ok(results)
}

/// Creates or updates a secret in AWS Secrets Manager.
async fn set_secret_async(
&self,
Expand Down Expand Up @@ -272,4 +347,85 @@ impl Provider for AwssmProvider {
fn allows_set(&self) -> bool {
true
}

fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
super::block_on(self.get_batch_async(project, keys, profile))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_format_secret_name() {
let name = AwssmProvider::format_secret_name("myapp", "prod", "DB_URL").unwrap();
assert_eq!(name, "secretspec/myapp/prod/DB_URL");
}

#[test]
fn test_format_secret_name_too_long() {
let long_key = "A".repeat(500);
let result = AwssmProvider::format_secret_name("myapp", "prod", &long_key);
assert!(result.is_err());
}

#[test]
fn test_format_secret_name_empty_inputs() {
assert!(AwssmProvider::format_secret_name("", "prod", "KEY").is_err());
assert!(AwssmProvider::format_secret_name("proj", "", "KEY").is_err());
assert!(AwssmProvider::format_secret_name("proj", "prod", "").is_err());
}

#[test]
fn test_build_batch_request_names() {
let keys: Vec<&str> = vec!["A", "B", "C"];
let (secret_names, name_to_key) =
AwssmProvider::build_batch_request_names("proj", &keys, "default").unwrap();

assert_eq!(secret_names.len(), 3);
assert_eq!(name_to_key.len(), 3);
assert_eq!(secret_names[0], "secretspec/proj/default/A");
assert_eq!(name_to_key["secretspec/proj/default/A"], "A");
assert_eq!(name_to_key["secretspec/proj/default/B"], "B");
assert_eq!(name_to_key["secretspec/proj/default/C"], "C");
}

#[test]
fn test_build_batch_request_names_empty() {
let keys: Vec<&str> = vec![];
let (secret_names, name_to_key) =
AwssmProvider::build_batch_request_names("proj", &keys, "default").unwrap();
assert!(secret_names.is_empty());
assert!(name_to_key.is_empty());
}

#[test]
fn test_build_batch_request_names_chunking() {
let keys: Vec<String> = (0..45).map(|i| format!("SECRET_{}", i)).collect();
let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();

let (secret_names, name_to_key) =
AwssmProvider::build_batch_request_names("proj", &key_refs, "default").unwrap();

assert_eq!(secret_names.len(), 45);
assert_eq!(name_to_key.len(), 45);

let chunks: Vec<&[String]> = secret_names.chunks(AWS_BATCH_GET_MAX_SECRETS).collect();
assert_eq!(chunks.len(), 3); // 20 + 20 + 5
assert_eq!(chunks[0].len(), 20);
assert_eq!(chunks[1].len(), 20);
assert_eq!(chunks[2].len(), 5);

// Verify reverse mapping is correct for all keys
for key in &key_refs {
let name = AwssmProvider::format_secret_name("proj", "default", key).unwrap();
assert_eq!(name_to_key[&name], *key);
}
}
}
45 changes: 45 additions & 0 deletions secretspec/src/provider/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,51 @@ mod integration_tests {
);
}

#[cfg(feature = "awssm")]
#[test]
fn test_awssm_batch_get() {
let providers = get_test_providers();
if !providers.contains(&"awssm".to_string()) {
return;
}

let (provider, _temp_dir) = create_provider_with_temp_path("awssm");
let project_name = generate_test_project_name();
let profile = "default";

// Set up test secrets
let test_secrets = vec![
("BATCH_TEST_1", "value1"),
("BATCH_TEST_2", "value2"),
("BATCH_TEST_3", "value3"),
];
for (key, value) in &test_secrets {
provider
.set(
&project_name,
key,
&SecretString::new(value.to_string().into()),
profile,
)
.unwrap();
}

// Batch get including a key that doesn't exist
let keys = vec![
"BATCH_TEST_1",
"BATCH_TEST_2",
"BATCH_TEST_3",
"NONEXISTENT",
];
let result = provider.get_batch(&project_name, &keys, profile).unwrap();

assert_eq!(result.len(), 3);
assert_eq!(result["BATCH_TEST_1"].expose_secret(), "value1");
assert_eq!(result["BATCH_TEST_2"].expose_secret(), "value2");
assert_eq!(result["BATCH_TEST_3"].expose_secret(), "value3");
assert!(!result.contains_key("NONEXISTENT"));
}

#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_creation() {
Expand Down
Loading