Skip to content

Commit 8c898f0

Browse files
authored
Merge pull request #134 from cachix/feat/awssm-tags-kms
feat(awssm): support KMS key and tags on secret creation
2 parents 5625f10 + 0eecc45 commit 8c898f0

3 files changed

Lines changed: 176 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- The `awssm` provider accepts `kms_key_id` and `tag.NAME=VALUE` query
12+
parameters (e.g. `awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform`).
13+
Both are applied only when secretspec creates a secret, so accounts that enforce
14+
a customer-managed KMS key or "tag-on-create" guardrails (an SCP requiring
15+
`aws:RequestTag/*` on `CreateSecret`) can now store secrets. A pre-existing
16+
secret keeps the key and tags it was created with.
1117
- PHP SDK (`cachix/secretspec`): resolve secrets from PHP, Laravel, and Symfony
1218
over the same shared resolver as the other language SDKs. It ships as a native
1319
PHP extension that embeds the resolver (works under PHP-FPM with no

docs/src/content/docs/providers/awssm.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,21 @@ The AWS Secrets Manager provider integrates with AWS for centralized secret mana
1616
### URI Format
1717

1818
```
19-
awssm://[AWS_PROFILE@]REGION[?prefix=PREFIX]
19+
awssm://[AWS_PROFILE@]REGION[?prefix=PREFIX][&kms_key_id=KEY][&tag.NAME=VALUE...]
2020
```
2121

2222
- `REGION`: AWS region (e.g., `us-east-1`). If omitted, the SDK default region chain is used.
2323
- `AWS_PROFILE`: Optional AWS profile from `~/.aws/credentials`. If omitted, the SDK default credential chain is used.
2424
- `PREFIX`: Optional root prefix prepended to all secret names. Useful when IAM policies scope access by prefix (e.g., only allow `myteam/*`).
25+
- `kms_key_id`: Optional KMS key (id, ARN, or `alias/...`) used to encrypt secrets that secretspec creates.
26+
- `tag.NAME=VALUE`: Optional tags applied to secrets that secretspec creates. Repeat for multiple tags.
27+
28+
`kms_key_id` and `tag.NAME=VALUE` are applied **only when secretspec creates a
29+
secret** (`CreateSecret`); updating a value (`PutSecretValue`) accepts neither,
30+
and a pre-existing secret keeps the key and tags it was created with. This
31+
supports AWS "tag-on-create" guardrails, where an SCP or IAM condition denies
32+
`CreateSecret` unless required `aws:RequestTag/*` tags (and often a
33+
customer-managed key) are present in the same call.
2534

2635
### Examples
2736

@@ -35,6 +44,9 @@ $ secretspec check --provider awssm://production@us-east-1
3544
# Use a prefix to scope secrets under "myteam/"
3645
$ secretspec set DATABASE_URL --provider "awssm://us-east-1?prefix=myteam"
3746

47+
# Create secrets with a customer-managed KMS key and required tags
48+
$ secretspec set DATABASE_URL --provider "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform&tag.env=prod"
49+
3850
# Get a secret
3951
$ secretspec get DATABASE_URL --provider awssm://us-east-1
4052

@@ -45,6 +57,15 @@ $ secretspec run --provider awssm://us-east-1 -- npm start
4557
$ secretspec set DATABASE_URL --provider awssm
4658
```
4759

60+
Because guardrail tags and keys usually vary per environment, they are a natural
61+
fit for a checked-in [provider alias](/reference/configuration/) in
62+
`secretspec.toml`:
63+
64+
```toml
65+
[providers]
66+
prod = "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform&tag.env=prod"
67+
```
68+
4869
## Secret References
4970

5071
By default each secret is stored under `secretspec/{project}/{profile}/{key}`. A
@@ -122,6 +143,11 @@ arn:aws:secretsmanager:*:*:secret:myteam/secretspec/*
122143
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.
123144
:::
124145

146+
:::note
147+
Using `tag.NAME=VALUE` additionally requires `secretsmanager:TagResource`, and a
148+
`kms_key_id` requires `kms:GenerateDataKey` and `kms:Decrypt` on that key.
149+
:::
150+
125151
### CI/CD
126152

127153
```bash

secretspec/src/provider/awssm.rs

Lines changed: 143 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
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
@@ -26,6 +28,15 @@
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
@@ -39,9 +50,10 @@
3950
use super::{Address, Provider, ProviderUrl};
4051
use crate::{Result, SecretSpecError};
4152
use aws_sdk_secretsmanager::Client;
53+
use aws_sdk_secretsmanager::types::Tag;
4254
use secrecy::{ExposeSecret, SecretString};
4355
use serde::{Deserialize, Serialize};
44-
use std::collections::HashMap;
56+
use std::collections::{BTreeMap, HashMap};
4557

4658
/// Maximum number of secrets per BatchGetSecretValue API call.
4759
const 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

6281
impl 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

125160
impl 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

Comments
 (0)