use crate::error::{ErrorData, Result};
use crate::traits::SecretPresence;
use alien_aws_clients::ssm::{
DescribeParametersRequest, GetParameterRequest, ParameterStringFilter, PutParameterRequest,
SsmApi, SsmClient,
};
use alien_error::{Context, ContextError};
use async_trait::async_trait;
use std::sync::Arc;
#[derive(Debug)]
pub struct AwsParameterStoreVault {
client: Arc<SsmClient>,
vault_prefix: String,
}
impl AwsParameterStoreVault {
pub fn new(client: Arc<SsmClient>, vault_prefix: String) -> Self {
Self {
client,
vault_prefix,
}
}
fn full_parameter_name(&self, secret_name: &str) -> String {
alien_core::vault_naming::parameter_store_parameter_name(&self.vault_prefix, secret_name)
}
}
#[async_trait]
impl crate::traits::Binding for AwsParameterStoreVault {}
#[async_trait]
impl crate::traits::Vault for AwsParameterStoreVault {
async fn get_secret(&self, secret_name: &str) -> Result<String> {
let full_name = self.full_parameter_name(secret_name);
let request = GetParameterRequest::builder()
.name(full_name.clone())
.with_decryption(true)
.build();
let response = self.client.get_parameter(request).await.map_err(|error| {
super::secret_read_error(
error,
&self.vault_prefix,
secret_name,
format!("Failed to get parameter '{full_name}'"),
)
})?;
let parameter = response.parameter.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::CloudPlatformError {
message: format!("Parameter '{}' missing in response", full_name),
resource_id: None,
})
})?;
parameter.value.ok_or_else(|| {
alien_error::AlienError::new(ErrorData::CloudPlatformError {
message: format!("Parameter '{}' has no value", full_name),
resource_id: None,
})
})
}
async fn secret_presence(&self, secret_name: &str) -> Result<SecretPresence> {
let full_name = self.full_parameter_name(secret_name);
let request = DescribeParametersRequest::builder()
.parameter_filters(vec![ParameterStringFilter::builder()
.key("Name".to_string())
.option("Equals".to_string())
.values(vec![full_name.clone()])
.build()])
.build();
let response = self.client.describe_parameters(request).await.context(
ErrorData::CloudPlatformError {
message: format!("Failed to describe parameter '{full_name}'"),
resource_id: None,
},
)?;
let Some(parameter) = response.parameters.unwrap_or_default().into_iter().next() else {
return Ok(SecretPresence::Missing);
};
match parameter.parameter_type.as_deref() {
Some("SecureString") => Ok(SecretPresence::Present),
other => Ok(SecretPresence::Invalid {
reason: format!(
"parameter '{full_name}' is a {} parameter; it must be a SecureString",
other.unwrap_or("untyped")
),
}),
}
}
async fn set_secret(&self, secret_name: &str, value: &str) -> Result<()> {
let full_name = self.full_parameter_name(secret_name);
let request = PutParameterRequest::builder()
.name(full_name.clone())
.value(value.to_string())
.parameter_type("SecureString".to_string())
.description(format!(
"Secret managed by Alien vault {}",
self.vault_prefix
))
.overwrite(true)
.build();
self.client
.put_parameter(request)
.await
.context(ErrorData::CloudPlatformError {
message: format!("Failed to put parameter '{}'", full_name),
resource_id: None,
})?;
Ok(())
}
async fn delete_secret(&self, secret_name: &str) -> Result<()> {
let full_name = self.full_parameter_name(secret_name);
match self.client.delete_parameter(&full_name).await {
Ok(_) => Ok(()),
Err(error)
if matches!(
error.error,
Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
) =>
{
Ok(())
}
Err(error) => Err(error.context(ErrorData::CloudPlatformError {
message: format!("Failed to delete parameter '{full_name}'"),
resource_id: None,
})),
}
}
async fn list_secrets(&self) -> Result<Vec<String>> {
Err(alien_error::AlienError::new(
ErrorData::OperationNotSupported {
operation: "vault.list_secrets".to_string(),
reason: format!(
"AWS Parameter Store names are flat ('{{vault_prefix}}-{{name}}'); a \
BeginsWith filter on vault prefix '{}' would also match sibling vaults \
whose prefix extends this one (e.g. '{}-prod'). Listing will return once \
the naming scheme has unambiguous separation between vault prefix and \
secret name.",
self.vault_prefix, self.vault_prefix
),
},
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Vault as _;
use alien_aws_clients::AwsCredentialProvider;
use alien_core::{AwsClientConfig, AwsCredentials};
async fn test_vault() -> AwsParameterStoreVault {
let config = AwsClientConfig {
account_id: "000000000000".to_string(),
region: "us-east-1".to_string(),
credentials: AwsCredentials::AccessKeys {
access_key_id: "test".to_string(),
secret_access_key: "test".to_string(),
session_token: None,
},
service_overrides: None,
};
let credentials = AwsCredentialProvider::from_config(config)
.await
.expect("static access-key credentials never fail to construct");
let client = Arc::new(SsmClient::new(reqwest::Client::new(), credentials));
AwsParameterStoreVault::new(client, "app".to_string())
}
#[tokio::test]
async fn list_secrets_reports_not_supported() {
let vault = test_vault().await;
let error = vault
.list_secrets()
.await
.expect_err("list_secrets must stay withheld until namespace isolation lands");
assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
assert!(
error.to_string().contains("app"),
"message should name the vault prefix so operators can diagnose which vault hit \
this, got: {error}"
);
}
}