Skip to main content

hasp_backend_aws_ssm/
lib.rs

1//! `aws-ssm://` backend for hasp.
2//!
3//! Grammar: `aws-ssm://<region>/<parameter-name>?with-decryption=<bool>`
4//!   - `<region>`          — AWS region (host). Must be non-empty.
5//!   - `<parameter-name>`  — Parameter Store name (path). All leading
6//!     `/` characters are stripped; hierarchical parameters that require a
7//!     leading `/` must be encoded with a double slash after the host.
8//!   - `?with-decryption`  — Optional boolean (default `true`). Pass
9//!     `false` to fetch a `SecureString` value without invoking KMS.
10//!
11//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
12//!
13//! Authentication is ambient only: `AWS_ACCESS_KEY_ID` +
14//! `AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, IAM role via IMDS/ECS/EKS, or
15//! any other source supported by the AWS default credential chain. No
16//! auth-bootstrap flows or credential refresh logic lives in this crate.
17//!
18//! SSM parameters may be `String`, `StringList`, or `SecureString`.
19//! All three expose their value as text through this backend.
20
21use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
22use url::Url;
23
24/// URL shape for `aws-ssm://` addresses.
25///
26/// Region and parameter name are identifiers, not secret values. They may
27/// appear in error messages (redacted per URL discipline).
28#[derive(Debug)]
29pub struct AwsSsmUrl {
30    pub region: String,
31    pub parameter_name: String,
32    pub with_decryption: bool,
33}
34
35impl TryFrom<&Url> for AwsSsmUrl {
36    type Error = Error;
37
38    fn try_from(url: &Url) -> Result<Self, Self::Error> {
39        if url.scheme() != "aws-ssm" {
40            return Err(Error::InvalidUrl("expected aws-ssm:// scheme".into()));
41        }
42
43        let region = url
44            .host_str()
45            .ok_or_else(|| Error::InvalidUrl("aws-ssm:// requires a region (host)".into()))?
46            .to_owned();
47        if region.is_empty() {
48            return Err(Error::InvalidUrl(
49                "aws-ssm:// region must not be empty".into(),
50            ));
51        }
52
53        let parameter_name = url.path().trim_start_matches('/').to_owned();
54        if parameter_name.is_empty() {
55            return Err(Error::InvalidUrl(
56                "aws-ssm:// parameter name must not be empty".into(),
57            ));
58        }
59
60        let mut with_decryption = true;
61
62        for (k, v) in url.query_pairs() {
63            match k.as_ref() {
64                "with-decryption" => {
65                    with_decryption = match v.as_ref() {
66                        "true" => true,
67                        "false" => false,
68                        _ => {
69                            return Err(Error::InvalidUrl(format!(
70                                "aws-ssm:// with-decryption must be true or false, got {v}"
71                            )));
72                        }
73                    };
74                }
75                _ => {
76                    return Err(Error::InvalidUrl(format!(
77                        "aws-ssm:// unknown query parameter: {k}"
78                    )));
79                }
80            }
81        }
82
83        Ok(AwsSsmUrl {
84            region,
85            parameter_name,
86            with_decryption,
87        })
88    }
89}
90
91/// AWS SSM Parameter Store SDK backend.
92///
93/// Construction attempts to build a Tokio runtime so the async AWS SDK
94/// can be used from the sync `Backend` trait. The runtime is
95/// `current_thread` to keep the backend lightweight for short-lived CLI
96/// invocations. If runtime creation fails, the error is stored and
97/// replayed on the first operation.
98///
99/// **Note:** Explicit proxy configuration is not yet supported for AWS SDK
100/// backends. Use the `HTTPS_PROXY`/`HTTP_PROXY` environment variables instead.
101#[derive(Debug)]
102pub struct AwsSsmBackend {
103    init: Result<tokio::runtime::Runtime, Error>,
104}
105
106impl AwsSsmBackend {
107    /// Create a new `AwsSsmBackend`.
108    ///
109    /// Errors on construction are deferred to first use so
110    /// `Store::with_defaults()` never panics.
111    pub fn new() -> Self {
112        Self {
113            init: tokio::runtime::Builder::new_current_thread()
114                .enable_io()
115                .enable_time()
116                .build()
117                .map_err(|e| Error::Backend {
118                    scheme: "aws-ssm",
119                    kind: BackendFailureKind::Permanent,
120                    message: format!("failed to create tokio runtime: {e}"),
121                }),
122        }
123    }
124
125    /// Create a new `AwsSsmBackend` with an explicit proxy.
126    ///
127    /// **Note:** Explicit proxy configuration is not yet supported for
128    /// AWS SDK backends. Use the `HTTPS_PROXY`/`HTTP_PROXY` environment
129    /// variables instead.
130    pub fn with_proxy(_proxy: Option<hasp_core::ProxyConfig>) -> Self {
131        Self::new()
132    }
133
134    fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
135        self.init.as_ref().map_err(|e| e.clone())
136    }
137
138    fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
139    where
140        F: std::future::Future,
141    {
142        let rt = self.runtime()?;
143        Ok(rt.block_on(future))
144    }
145}
146
147impl Default for AwsSsmBackend {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl Backend for AwsSsmBackend {
154    fn scheme(&self) -> &'static str {
155        "aws-ssm"
156    }
157
158    fn validate(&self, url: &Url) -> Result<(), Error> {
159        AwsSsmUrl::try_from(url).map(|_| ())
160    }
161
162    fn get(&self, url: &Url) -> Result<SecretString, Error> {
163        let aws_url = AwsSsmUrl::try_from(url)?;
164        self.block_on(get_parameter(&aws_url, aws_url.with_decryption))?
165    }
166
167    fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
168        let aws_url = AwsSsmUrl::try_from(url)?;
169        self.block_on(put_parameter(&aws_url, value.expose_secret()))?
170    }
171
172    fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
173        let aws_url = AwsSsmUrl::try_from(url)?;
174        self.block_on(list_parameters(&aws_url))?
175    }
176
177    fn delete(&self, url: &Url) -> Result<(), Error> {
178        let aws_url = AwsSsmUrl::try_from(url)?;
179        self.block_on(delete_parameter(&aws_url))?
180    }
181
182    fn exists(&self, url: &Url) -> Result<bool, Error> {
183        let aws_url = AwsSsmUrl::try_from(url)?;
184        match self.block_on(get_parameter(&aws_url, false)) {
185            Ok(_) => Ok(true),
186            Err(Error::NotFound(_)) => Ok(false),
187            Err(e) => Err(e),
188        }
189    }
190}
191
192/// Build an AWS SDK config scoped to the given region.
193async fn aws_config_for_region(region: &str) -> aws_config::SdkConfig {
194    aws_config::defaults(aws_config::BehaviorVersion::latest())
195        .region(aws_config::Region::new(region.to_string()))
196        .load()
197        .await
198}
199
200/// Fetch a parameter value via `GetParameter`.
201///
202/// `with_decryption` controls whether KMS decrypts a `SecureString`.
203/// For `exists` checks this should be `false` to avoid unnecessary KMS
204/// calls; for `get` it should follow the URL query parameter.
205async fn get_parameter(aws_url: &AwsSsmUrl, with_decryption: bool) -> Result<SecretString, Error> {
206    let config = aws_config_for_region(&aws_url.region).await;
207    let client = aws_sdk_ssm::Client::new(&config);
208
209    let output = client
210        .get_parameter()
211        .name(&aws_url.parameter_name)
212        .with_decryption(with_decryption)
213        .send()
214        .await
215        .map_err(map_get_error)?;
216
217    let parameter = output.parameter.ok_or_else(|| Error::Backend {
218        scheme: "aws-ssm",
219        kind: BackendFailureKind::Permanent,
220        message: "AWS returned an empty parameter object".into(),
221    })?;
222
223    let value = parameter.value.ok_or_else(|| Error::Backend {
224        scheme: "aws-ssm",
225        kind: BackendFailureKind::Permanent,
226        message: "AWS returned a parameter with no value".into(),
227    })?;
228
229    Ok(SecretString::new(value.into()))
230}
231
232/// Store or update a parameter via `PutParameter`.
233///
234/// Uses `SecureString` type and the same `with_decryption` KMS posture as
235/// `get`. Creates a new version each time; callers cannot rollback. AWS
236/// native behavior — DeleteParameter is irreversible.
237async fn put_parameter(aws_url: &AwsSsmUrl, value: &str) -> Result<(), Error> {
238    let config = aws_config_for_region(&aws_url.region).await;
239    let client = aws_sdk_ssm::Client::new(&config);
240
241    client
242        .put_parameter()
243        .name(&aws_url.parameter_name)
244        .value(value)
245        .r#type(aws_sdk_ssm::types::ParameterType::SecureString)
246        .overwrite(true)
247        .send()
248        .await
249        .map_err(map_put_error)?;
250
251    Ok(())
252}
253
254/// List parameters via `GetParametersByPath`.
255///
256/// The URL path serves as the hierarchical prefix. AWS SSM list returns
257/// every parameter under the given path. Transparent pagination follows
258/// `NextToken` until exhausted (bounded at 500 pages).
259async fn list_parameters(aws_url: &AwsSsmUrl) -> Result<Vec<Entry>, Error> {
260    let config = aws_config_for_region(&aws_url.region).await;
261    let client = aws_sdk_ssm::Client::new(&config);
262
263    let mut entries = Vec::new();
264    let mut next_token: Option<String> = None;
265    const MAX_PAGES: usize = 500;
266
267    for _ in 0..MAX_PAGES {
268        let mut builder = client
269            .get_parameters_by_path()
270            .path(&aws_url.parameter_name)
271            .recursive(true);
272
273        if let Some(ref token) = next_token {
274            builder = builder.next_token(token);
275        }
276
277        let output = builder.send().await.map_err(map_list_error)?;
278        next_token = output.next_token.clone();
279
280        for param in output.parameters.into_iter().flatten() {
281            let name = param.name.unwrap_or_default();
282            if name.is_empty() {
283                continue;
284            }
285            let entry_url = Url::parse(&format!(
286                "aws-ssm://{}/{}?with-decryption={}",
287                aws_url.region, name, aws_url.with_decryption,
288            ))
289            .map_err(|e| Error::Backend {
290                scheme: "aws-ssm",
291                kind: BackendFailureKind::Permanent,
292                message: format!("failed to build list entry URL: {e}"),
293            })?;
294            entries.push(Entry {
295                name,
296                url: entry_url,
297            });
298        }
299
300        if next_token.is_none() {
301            break;
302        }
303    }
304
305    Ok(entries)
306}
307
308/// Delete a parameter via `DeleteParameter`.
309///
310/// AWS native behavior: removes the parameter and all versions.
311async fn delete_parameter(aws_url: &AwsSsmUrl) -> Result<(), Error> {
312    let config = aws_config_for_region(&aws_url.region).await;
313    let client = aws_sdk_ssm::Client::new(&config);
314
315    client
316        .delete_parameter()
317        .name(&aws_url.parameter_name)
318        .send()
319        .await
320        .map_err(map_delete_error)?;
321
322    Ok(())
323}
324
325/// Map a `GetParameter` SDK error into the locked `hasp_core::Error`
326/// taxonomy.
327fn map_get_error(
328    err: aws_sdk_ssm::error::SdkError<aws_sdk_ssm::operation::get_parameter::GetParameterError>,
329) -> Error {
330    if let Some(service_err) = err.as_service_error() {
331        let code = service_err.meta().code().unwrap_or("Unknown");
332        let message = service_err.meta().message().unwrap_or("no message");
333        return from_service_error(code, message);
334    }
335    map_generic_error(err)
336}
337
338/// Map a `PutParameter` SDK error into the locked `hasp_core::Error` taxonomy.
339fn map_put_error(
340    err: aws_sdk_ssm::error::SdkError<aws_sdk_ssm::operation::put_parameter::PutParameterError>,
341) -> Error {
342    if let Some(service_err) = err.as_service_error() {
343        let code = service_err.meta().code().unwrap_or("Unknown");
344        let message = service_err.meta().message().unwrap_or("no message");
345        return from_service_error(code, message);
346    }
347    map_generic_error(err)
348}
349
350/// Map a `GetParametersByPath` SDK error into the locked `hasp_core::Error` taxonomy.
351fn map_list_error(
352    err: aws_sdk_ssm::error::SdkError<
353        aws_sdk_ssm::operation::get_parameters_by_path::GetParametersByPathError,
354    >,
355) -> Error {
356    if let Some(service_err) = err.as_service_error() {
357        let code = service_err.meta().code().unwrap_or("Unknown");
358        let message = service_err.meta().message().unwrap_or("no message");
359        return from_service_error(code, message);
360    }
361    map_generic_error(err)
362}
363
364/// Map a `DeleteParameter` SDK error into the locked `hasp_core::Error` taxonomy.
365fn map_delete_error(
366    err: aws_sdk_ssm::error::SdkError<
367        aws_sdk_ssm::operation::delete_parameter::DeleteParameterError,
368    >,
369) -> Error {
370    if let Some(service_err) = err.as_service_error() {
371        let code = service_err.meta().code().unwrap_or("Unknown");
372        let message = service_err.meta().message().unwrap_or("no message");
373        return from_service_error(code, message);
374    }
375    map_generic_error(err)
376}
377
378/// Convert AWS SSM service error metadata into a stable `hasp_core::Error`.
379// TODO(#4): validate against live AWS account — see notes/TODO-live-error-mapping.md
380fn from_service_error(code: &str, message: &str) -> Error {
381    match code {
382        "ParameterNotFound" | "ParameterVersionNotFound" => {
383            Error::NotFound(format!("aws-ssm:// parameter not found: {message}"))
384        }
385        "InvalidParameterException" | "InvalidParameterValue" | "ParameterPatternMismatch" => {
386            Error::InvalidUrl(format!("aws-ssm:// invalid parameter: {message}"))
387        }
388        "InvalidRequestException" => {
389            Error::PreconditionFailed(format!("aws-ssm:// request precondition failed: {message}"))
390        }
391        "AccessDeniedException" => {
392            Error::PermissionDenied(format!("aws-ssm:// permission denied: {message}"))
393        }
394        "UnauthorizedException" => {
395            Error::AuthenticationFailed(format!("aws-ssm:// authentication failed: {message}"))
396        }
397        "ThrottlingException" | "TooManyUpdates" => Error::Backend {
398            scheme: "aws-ssm",
399            kind: BackendFailureKind::Throttled,
400            message: format!("AWS throttled the request: {message}"),
401        },
402        "InternalServerError" => Error::Backend {
403            scheme: "aws-ssm",
404            kind: BackendFailureKind::Transient,
405            message: format!("AWS service error ({code}): {message}"),
406        },
407        "HierarchyDepthLimitExceeded" | "ParameterAlreadyExists" | "ParameterLimitExceeded" => {
408            Error::PreconditionFailed(format!("aws-ssm:// precondition failed: {message}"))
409        }
410        "InvalidKeyId" | "UnsupportedParameterType" => Error::Backend {
411            scheme: "aws-ssm",
412            kind: BackendFailureKind::Permanent,
413            message: format!("AWS service error ({code}): {message}"),
414        },
415        _ => Error::Backend {
416            scheme: "aws-ssm",
417            kind: BackendFailureKind::Permanent,
418            message: format!("AWS service error ({code}): {message}"),
419        },
420    }
421}
422
423/// Map non-service SDK errors (timeouts, dispatch failures, construction
424/// failures) into the locked `hasp_core::Error` taxonomy.
425fn map_generic_error<E: std::fmt::Display>(err: aws_sdk_ssm::error::SdkError<E>) -> Error {
426    use aws_sdk_ssm::error::SdkError;
427    match err {
428        SdkError::TimeoutError(_) => Error::Backend {
429            scheme: "aws-ssm",
430            kind: BackendFailureKind::Transient,
431            message: "AWS request timed out".into(),
432        },
433        SdkError::DispatchFailure(_) => Error::Backend {
434            scheme: "aws-ssm",
435            kind: BackendFailureKind::Transient,
436            message: "AWS request dispatch failed".into(),
437        },
438        _ => Error::Backend {
439            scheme: "aws-ssm",
440            kind: BackendFailureKind::Permanent,
441            message: format!("AWS SDK error: {err}"),
442        },
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn parse_valid_url_simple() {
452        let url = Url::parse("aws-ssm://us-east-1/my-param").unwrap();
453        let aws = AwsSsmUrl::try_from(&url).unwrap();
454        assert_eq!(aws.region, "us-east-1");
455        assert_eq!(aws.parameter_name, "my-param");
456        assert!(aws.with_decryption);
457    }
458
459    #[test]
460    fn parse_valid_url_with_path() {
461        let url = Url::parse("aws-ssm://us-west-2/prod/app/db-password").unwrap();
462        let aws = AwsSsmUrl::try_from(&url).unwrap();
463        assert_eq!(aws.region, "us-west-2");
464        assert_eq!(aws.parameter_name, "prod/app/db-password");
465    }
466
467    #[test]
468    fn parse_valid_url_with_decryption_false() {
469        let url = Url::parse("aws-ssm://eu-west-1/my-param?with-decryption=false").unwrap();
470        let aws = AwsSsmUrl::try_from(&url).unwrap();
471        assert!(!aws.with_decryption);
472    }
473
474    #[test]
475    fn parse_valid_url_with_decryption_true() {
476        let url = Url::parse("aws-ssm://ap-south-1/my-param?with-decryption=true").unwrap();
477        let aws = AwsSsmUrl::try_from(&url).unwrap();
478        assert!(aws.with_decryption);
479    }
480
481    #[test]
482    fn parse_missing_host_fails() {
483        let url = Url::parse("aws-ssm:///my-param").unwrap();
484        assert!(AwsSsmUrl::try_from(&url).is_err());
485    }
486
487    #[test]
488    fn parse_empty_path_fails() {
489        let url = Url::parse("aws-ssm://us-east-1/").unwrap();
490        assert!(AwsSsmUrl::try_from(&url).is_err());
491    }
492
493    #[test]
494    fn parse_unknown_query_fails() {
495        let url = Url::parse("aws-ssm://us-east-1/my-param?version=1").unwrap();
496        assert!(AwsSsmUrl::try_from(&url).is_err());
497    }
498
499    #[test]
500    fn parse_invalid_decryption_value_fails() {
501        let url = Url::parse("aws-ssm://us-east-1/my-param?with-decryption=maybe").unwrap();
502        assert!(AwsSsmUrl::try_from(&url).is_err());
503    }
504
505    #[test]
506    fn error_map_parameter_not_found() {
507        let err = from_service_error("ParameterNotFound", "parameter not found");
508        assert!(matches!(err, Error::NotFound(ref s) if s.contains("parameter not found")));
509    }
510
511    #[test]
512    fn error_map_parameter_version_not_found() {
513        let err = from_service_error("ParameterVersionNotFound", "version gone");
514        assert!(matches!(err, Error::NotFound(ref s) if s.contains("version gone")));
515    }
516
517    #[test]
518    fn error_map_invalid_parameter() {
519        let err = from_service_error("InvalidParameterException", "bad param");
520        assert!(matches!(err, Error::InvalidUrl(ref s) if s.contains("bad param")));
521    }
522
523    #[test]
524    fn error_map_invalid_request() {
525        let err = from_service_error("InvalidRequestException", "bad request");
526        assert!(matches!(err, Error::PreconditionFailed(ref s) if s.contains("bad request")));
527    }
528
529    #[test]
530    fn error_map_access_denied() {
531        let err = from_service_error("AccessDeniedException", "denied");
532        assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("denied")));
533    }
534
535    #[test]
536    fn error_map_unauthorized() {
537        let err = from_service_error("UnauthorizedException", "who are you");
538        assert!(matches!(err, Error::AuthenticationFailed(ref s) if s.contains("who are you")));
539    }
540
541    #[test]
542    fn error_map_throttling() {
543        let err = from_service_error("ThrottlingException", "slow down");
544        assert!(matches!(
545            err,
546            Error::Backend {
547                kind: BackendFailureKind::Throttled,
548                ..
549            }
550        ));
551    }
552
553    #[test]
554    fn error_map_internal_server_error_is_transient() {
555        let err = from_service_error("InternalServerError", "oops");
556        assert!(matches!(
557            err,
558            Error::Backend {
559                kind: BackendFailureKind::Transient,
560                ..
561            }
562        ));
563    }
564
565    #[test]
566    fn error_map_unknown_code_is_permanent() {
567        let err = from_service_error("SomeWeirdException", "unknown");
568        assert!(matches!(
569            err,
570            Error::Backend {
571                kind: BackendFailureKind::Permanent,
572                ..
573            }
574        ));
575    }
576
577    #[test]
578    fn supported_operations() {
579        let _backend = AwsSsmBackend::new();
580        // put, list, delete are implemented; they fail at network layer
581        // because no AWS credentials are configured in unit tests.
582    }
583}