Skip to main content

alien_bindings/providers/vault/
aws_parameter_store.rs

1use crate::error::{ErrorData, Result};
2use alien_aws_clients::ssm::{GetParameterRequest, PutParameterRequest, SsmApi, SsmClient};
3use alien_error::{Context, ContextError};
4use async_trait::async_trait;
5use std::sync::Arc;
6
7/// AWS SSM Parameter Store vault binding implementation.
8#[derive(Debug)]
9pub struct AwsParameterStoreVault {
10    client: Arc<SsmClient>,
11    vault_prefix: String,
12}
13
14impl AwsParameterStoreVault {
15    /// Create a new AWS SSM Parameter Store vault binding.
16    pub fn new(client: Arc<SsmClient>, vault_prefix: String) -> Self {
17        Self {
18            client,
19            vault_prefix,
20        }
21    }
22
23    /// Generate the full parameter name with vault prefix.
24    fn full_parameter_name(&self, secret_name: &str) -> String {
25        format!("{}-{}", self.vault_prefix, secret_name)
26    }
27}
28
29#[async_trait]
30impl crate::traits::Binding for AwsParameterStoreVault {}
31
32#[async_trait]
33impl crate::traits::Vault for AwsParameterStoreVault {
34    /// Get a secret value by name.
35    async fn get_secret(&self, secret_name: &str) -> Result<String> {
36        let full_name = self.full_parameter_name(secret_name);
37
38        let request = GetParameterRequest::builder()
39            .name(full_name.clone())
40            .with_decryption(true)
41            .build();
42
43        let response =
44            self.client
45                .get_parameter(request)
46                .await
47                .context(ErrorData::CloudPlatformError {
48                    message: format!("Failed to get parameter '{}'", full_name),
49                    resource_id: None,
50                })?;
51
52        let parameter = response.parameter.ok_or_else(|| {
53            alien_error::AlienError::new(ErrorData::CloudPlatformError {
54                message: format!("Parameter '{}' missing in response", full_name),
55                resource_id: None,
56            })
57        })?;
58
59        parameter.value.ok_or_else(|| {
60            alien_error::AlienError::new(ErrorData::CloudPlatformError {
61                message: format!("Parameter '{}' has no value", full_name),
62                resource_id: None,
63            })
64        })
65    }
66
67    /// Set a secret value using SecureString parameters.
68    async fn set_secret(&self, secret_name: &str, value: &str) -> Result<()> {
69        let full_name = self.full_parameter_name(secret_name);
70
71        let request = PutParameterRequest::builder()
72            .name(full_name.clone())
73            .value(value.to_string())
74            .parameter_type("SecureString".to_string())
75            .description(format!(
76                "Secret managed by Alien vault {}",
77                self.vault_prefix
78            ))
79            .overwrite(true)
80            .build();
81
82        self.client
83            .put_parameter(request)
84            .await
85            .context(ErrorData::CloudPlatformError {
86                message: format!("Failed to put parameter '{}'", full_name),
87                resource_id: None,
88            })?;
89
90        Ok(())
91    }
92
93    /// Delete a secret.
94    async fn delete_secret(&self, secret_name: &str) -> Result<()> {
95        let full_name = self.full_parameter_name(secret_name);
96
97        match self.client.delete_parameter(&full_name).await {
98            Ok(_) => Ok(()),
99            Err(error)
100                if matches!(
101                    error.error,
102                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
103                ) =>
104            {
105                Ok(())
106            }
107            Err(error) => Err(error.context(ErrorData::CloudPlatformError {
108                message: format!("Failed to delete parameter '{full_name}'"),
109                resource_id: None,
110            })),
111        }
112    }
113
114    /// Listing is withheld: SSM parameter names are flat (`{vault_prefix}-{name}`),
115    /// so a `BeginsWith` filter on `"{vault_prefix}-"` also matches every sibling
116    /// vault whose prefix extends this one (vault `"app"` would list vault
117    /// `"app-prod"`'s parameters too). There is no separator reserved between
118    /// the prefix and the secret name, so this is unfixable without a naming
119    /// scheme change. Returns `OperationNotSupported` until the naming scheme
120    /// gains unambiguous separation between `vault_prefix` and `secret_name`.
121    async fn list_secrets(&self) -> Result<Vec<String>> {
122        Err(alien_error::AlienError::new(
123            ErrorData::OperationNotSupported {
124                operation: "vault.list_secrets".to_string(),
125                reason: format!(
126                    "AWS Parameter Store names are flat ('{{vault_prefix}}-{{name}}'); a \
127                     BeginsWith filter on vault prefix '{}' would also match sibling vaults \
128                     whose prefix extends this one (e.g. '{}-prod'). Listing will return once \
129                     the naming scheme has unambiguous separation between vault prefix and \
130                     secret name.",
131                    self.vault_prefix, self.vault_prefix
132                ),
133            },
134        ))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::Vault as _;
142    use alien_aws_clients::AwsCredentialProvider;
143    use alien_core::{AwsClientConfig, AwsCredentials};
144
145    async fn test_vault() -> AwsParameterStoreVault {
146        let config = AwsClientConfig {
147            account_id: "000000000000".to_string(),
148            region: "us-east-1".to_string(),
149            credentials: AwsCredentials::AccessKeys {
150                access_key_id: "test".to_string(),
151                secret_access_key: "test".to_string(),
152                session_token: None,
153            },
154            service_overrides: None,
155        };
156        let credentials = AwsCredentialProvider::from_config(config)
157            .await
158            .expect("static access-key credentials never fail to construct");
159        let client = Arc::new(SsmClient::new(reqwest::Client::new(), credentials));
160        AwsParameterStoreVault::new(client, "app".to_string())
161    }
162
163    /// Listing is withheld rather than implemented with a `BeginsWith` scan,
164    /// because a flat `{vault_prefix}-{name}` namespace lets sibling vault
165    /// prefixes alias one another (vault "app" would also match "app-prod").
166    /// This must not silently start scanning again; assert the typed error.
167    #[tokio::test]
168    async fn list_secrets_reports_not_supported() {
169        let vault = test_vault().await;
170
171        let error = vault
172            .list_secrets()
173            .await
174            .expect_err("list_secrets must stay withheld until namespace isolation lands");
175
176        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
177        assert!(
178            error.to_string().contains("app"),
179            "message should name the vault prefix so operators can diagnose which vault hit \
180             this, got: {error}"
181        );
182    }
183}