1212//!
1313//! # URI Format
1414//!
15- //! `awssm://[aws-profile@]region[?prefix=PREFIX]`
15+ //! `awssm://[aws-profile@]region[?prefix=PREFIX][&kms_key_id=KEY][&tag.NAME=VALUE…] `
1616//!
1717//! - `awssm://us-east-1` — use SDK default credentials in us-east-1
1818//! - `awssm://production@us-east-1` — use the "production" AWS profile in us-east-1
1919//! - `awssm://us-east-1?prefix=myteam` — prefix all secret names with `myteam/`
20+ //! - `awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform&tag.env=prod`
21+ //! — encrypt with a customer-managed key and tag secrets on create
2022//! - `awssm://` — use SDK defaults for both profile and region
2123//!
2224//! # Secret Naming
2628//! When a `prefix` query parameter is set, it is prepended to the secret name,
2729//! allowing IAM policies to scope access (e.g. `arn:aws:secretsmanager:*:*:secret:myteam/*`).
2830//!
31+ //! # KMS Key and Tags
32+ //!
33+ //! The `kms_key_id` and `tag.NAME=VALUE` query parameters are applied **only when
34+ //! secretspec creates a secret** (`CreateSecret`); updates (`PutSecretValue`) accept
35+ //! neither, and a pre-existing secret keeps whatever key and tags it was created with.
36+ //! This supports AWS "tag-on-create" guardrails, where an SCP or IAM condition denies
37+ //! `CreateSecret` unless required `aws:RequestTag/*` tags (and often a customer-managed
38+ //! key) are present in the same call.
39+ //!
2940//! # Example
3041//!
3142//! ```bash
3950use super :: { Address , Provider , ProviderUrl } ;
4051use crate :: { Result , SecretSpecError } ;
4152use aws_sdk_secretsmanager:: Client ;
53+ use aws_sdk_secretsmanager:: types:: Tag ;
4254use secrecy:: { ExposeSecret , SecretString } ;
4355use serde:: { Deserialize , Serialize } ;
44- use std:: collections:: HashMap ;
56+ use std:: collections:: { BTreeMap , HashMap } ;
4557
4658/// Maximum number of secrets per BatchGetSecretValue API call.
4759const AWS_BATCH_GET_MAX_SECRETS : usize = 20 ;
@@ -57,6 +69,13 @@ pub struct AwssmConfig {
5769 /// `myteam/secretspec/{project}/{profile}/{key}`).
5870 /// Useful for scoping IAM policies by prefix.
5971 pub prefix : Option < String > ,
72+ /// KMS key (id, ARN, or `alias/…`) used to encrypt secrets this provider
73+ /// creates. Applied only on create; `None` uses the account's default key.
74+ pub kms_key_id : Option < String > ,
75+ /// Tags applied only when this provider creates a secret. A `BTreeMap` so
76+ /// iteration (and thus `uri()`) is deterministic for the audit log.
77+ #[ serde( default ) ]
78+ pub tags : BTreeMap < String , String > ,
6079}
6180
6281impl TryFrom < & ProviderUrl > for AwssmConfig {
@@ -84,6 +103,20 @@ impl TryFrom<&ProviderUrl> for AwssmConfig {
84103
85104 let prefix = url. query_value ( "prefix" ) ;
86105
106+ let kms_key_id = url. query_value ( "kms_key_id" ) ;
107+
108+ // Tags are collected from every `tag.NAME=VALUE` query pair. Iterating
109+ // `query_pairs` directly (rather than `query_value`) both collects the
110+ // whole namespaced set and preserves empty values, which AWS accepts.
111+ let tags: BTreeMap < String , String > = url
112+ . query_pairs ( )
113+ . filter_map ( |( k, v) | {
114+ k. strip_prefix ( "tag." )
115+ . filter ( |name| !name. is_empty ( ) )
116+ . map ( |name| ( name. to_string ( ) , v. into_owned ( ) ) )
117+ } )
118+ . collect ( ) ;
119+
87120 // The path reference form from earlier iterations is rejected with a
88121 // pointer at the `ref` table, instead of being silently ignored and
89122 // reading the conventional layout.
@@ -101,6 +134,8 @@ impl TryFrom<&ProviderUrl> for AwssmConfig {
101134 region,
102135 aws_profile,
103136 prefix,
137+ kms_key_id,
138+ tags,
104139 } )
105140 }
106141}
@@ -119,7 +154,7 @@ crate::register_provider! {
119154 name: "awssm" ,
120155 description: "AWS Secrets Manager" ,
121156 schemes: [ "awssm" ] ,
122- examples: [ "awssm://us-east-1" , "awssm://production@us-east-1" , "awssm://us-east-1?prefix=myteam" ] ,
157+ examples: [ "awssm://us-east-1" , "awssm://production@us-east-1" , "awssm://us-east-1?prefix=myteam" , "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform" ] ,
123158}
124159
125160impl AwssmProvider {
@@ -310,23 +345,34 @@ impl AwssmProvider {
310345 }
311346
312347 /// Creates or updates a secret at its full name in AWS Secrets Manager.
348+ ///
349+ /// The configured `kms_key_id` and `tags` are applied only on create;
350+ /// `PutSecretValue` accepts neither, so an existing secret keeps the key
351+ /// and tags it was created with.
313352 async fn set_secret_async ( & self , secret_name : & str , value : & SecretString ) -> Result < ( ) > {
314353 let client = self . create_client ( ) . await ?;
315354
316- // Try to create the secret first
317- let create_result = client
355+ // Try to create the secret first, applying the KMS key and tags. These
356+ // must ride the CreateSecret call itself to satisfy "tag-on-create"
357+ // guardrails (`aws:RequestTag`); a later TagResource would not.
358+ let mut create = client
318359 . create_secret ( )
319360 . name ( secret_name)
320- . secret_string ( value. expose_secret ( ) )
321- . send ( )
322- . await ;
361+ . secret_string ( value. expose_secret ( ) ) ;
362+ if let Some ( kms_key_id) = & self . config . kms_key_id {
363+ create = create. kms_key_id ( kms_key_id) ;
364+ }
365+ for ( key, val) in & self . config . tags {
366+ create = create. tags ( Tag :: builder ( ) . key ( key) . value ( val) . build ( ) ) ;
367+ }
323368
324- match create_result {
369+ match create . send ( ) . await {
325370 Ok ( _) => Ok ( ( ) ) ,
326371 Err ( err) => {
327372 let service_err = err. into_service_error ( ) ;
328373 if service_err. is_resource_exists_exception ( ) {
329- // Secret already exists, update it
374+ // Secret already exists, update its value (KMS key and tags
375+ // are create-only and left untouched here).
330376 client
331377 . put_secret_value ( )
332378 . secret_id ( secret_name)
@@ -376,17 +422,37 @@ impl Provider for AwssmProvider {
376422 ( None , Some ( region) ) => format ! ( "awssm://{}" , region) ,
377423 ( _, None ) => "awssm" . to_string ( ) ,
378424 } ;
379- match & self . config . prefix {
380- Some ( prefix) => {
381- let sep = if base. contains ( "://" ) { "?" } else { "://?" } ;
382- format ! (
383- "{}{}prefix={}" ,
384- base,
385- sep,
386- ProviderUrl :: encode_query( prefix)
387- )
388- }
389- None => base,
425+
426+ // Reconstruct every query parameter. `tags` is a BTreeMap, so it
427+ // iterates in sorted key order and `uri()` is deterministic.
428+ let mut params: Vec < String > = Vec :: new ( ) ;
429+ if let Some ( prefix) = & self . config . prefix {
430+ params. push ( format ! ( "prefix={}" , ProviderUrl :: encode_query( prefix) ) ) ;
431+ }
432+ if let Some ( kms_key_id) = & self . config . kms_key_id {
433+ params. push ( format ! (
434+ "kms_key_id={}" ,
435+ ProviderUrl :: encode_query( kms_key_id)
436+ ) ) ;
437+ }
438+ for ( key, value) in & self . config . tags {
439+ params. push ( format ! (
440+ "tag.{}={}" ,
441+ ProviderUrl :: encode_query( key) ,
442+ ProviderUrl :: encode_query( value)
443+ ) ) ;
444+ }
445+
446+ if params. is_empty ( ) {
447+ base
448+ } else {
449+ // Only the region-less `awssm` arm omits the `://` authority.
450+ let sep = if self . config . region . is_some ( ) {
451+ "?"
452+ } else {
453+ "://?"
454+ } ;
455+ format ! ( "{}{}{}" , base, sep, params. join( "&" ) )
390456 }
391457 }
392458
@@ -486,6 +552,62 @@ mod tests {
486552 assert_eq ! ( coords. item, "myteam/secretspec/proj/default/A" ) ;
487553 }
488554
555+ #[ test]
556+ fn parses_kms_key_id_and_tags ( ) {
557+ let c =
558+ config ( "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.env=prod&tag.team=platform" ) ;
559+ assert_eq ! ( c. kms_key_id. as_deref( ) , Some ( "alias/my-key" ) ) ;
560+ assert_eq ! ( c. tags. get( "env" ) . map( String :: as_str) , Some ( "prod" ) ) ;
561+ assert_eq ! ( c. tags. get( "team" ) . map( String :: as_str) , Some ( "platform" ) ) ;
562+ }
563+
564+ #[ test]
565+ fn absent_kms_and_tags_default_to_empty ( ) {
566+ let c = config ( "awssm://us-east-1" ) ;
567+ assert_eq ! ( c. kms_key_id, None ) ;
568+ assert ! ( c. tags. is_empty( ) ) ;
569+ }
570+
571+ /// AWS accepts empty tag values, and `tag.NAME=` carries a real (empty)
572+ /// value — kept rather than dropped like an empty `prefix`/`kms_key_id`.
573+ #[ test]
574+ fn empty_tag_value_is_kept ( ) {
575+ let c = config ( "awssm://us-east-1?tag.env=" ) ;
576+ assert_eq ! ( c. tags. get( "env" ) . map( String :: as_str) , Some ( "" ) ) ;
577+ }
578+
579+ /// `uri()` reconstructs every parameter and orders tags deterministically
580+ /// (BTreeMap), regardless of the order they appeared in the source URI, so
581+ /// the audit log stays stable.
582+ #[ test]
583+ fn uri_round_trips_kms_and_sorted_tags ( ) {
584+ let c = config ( "awssm://prod@us-east-1?tag.team=platform&kms_key_id=alias/k&tag.env=prod" ) ;
585+ let p = AwssmProvider :: new ( c) ;
586+ assert_eq ! (
587+ p. uri( ) ,
588+ "awssm://prod@us-east-1?kms_key_id=alias/k&tag.env=prod&tag.team=platform"
589+ ) ;
590+ // Re-parsing the reconstructed URI yields the same config.
591+ let reparsed = config ( & p. uri ( ) ) ;
592+ assert_eq ! ( reparsed. kms_key_id. as_deref( ) , Some ( "alias/k" ) ) ;
593+ assert_eq ! ( reparsed. tags. get( "env" ) . map( String :: as_str) , Some ( "prod" ) ) ;
594+ assert_eq ! (
595+ reparsed. tags. get( "team" ) . map( String :: as_str) ,
596+ Some ( "platform" )
597+ ) ;
598+ }
599+
600+ #[ test]
601+ fn uri_round_trips_prefix_with_kms_and_tags ( ) {
602+ let p = AwssmProvider :: new ( config (
603+ "awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod" ,
604+ ) ) ;
605+ assert_eq ! (
606+ p. uri( ) ,
607+ "awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod"
608+ ) ;
609+ }
610+
489611 #[ test]
490612 fn test_batch_chunking ( ) {
491613 let names: Vec < String > = ( 0 ..45 ) . map ( |i| format ! ( "SECRET_{}" , i) ) . collect ( ) ;
0 commit comments