diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05a2c619e..e72ab8f5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,6 +80,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Provider alias `ref` templates can interpolate non-secret process environment
+ values with `{env:NAME}`. Missing or non-UTF-8 variables fail before provider
+ access, and cached routes are invalidated when an expanded value changes.
+
- The age provider supports deleting secrets in 0.20+: `secretspec delete`,
`secretspec import --delete-source`, and cache invalidation now work with it,
so an age-encrypted file can serve as the local store of a cached provider
diff --git a/docs/src/content/docs/concepts/providers.mdx b/docs/src/content/docs/concepts/providers.mdx
index 512dffeac..cfa6e9d0c 100644
--- a/docs/src/content/docs/concepts/providers.mdx
+++ b/docs/src/content/docs/concepts/providers.mdx
@@ -187,8 +187,9 @@ DATABASE_URL = { description = "Production database", providers = ["onepassword:
A leaf alias can map the logical `{project}`, `{profile}`, and `{key}` into its
-provider's native coordinates. This lets every link in a fallback chain—and
-each side of an import—use a different address:
+provider's native coordinates. SecretSpec 0.21+ also supports `{env:NAME}` for
+non-secret values supplied by the current environment. This lets every link in
+a fallback chain—and each side of an import—use a different address:
```toml title="secretspec.toml"
[providers]
@@ -201,7 +202,8 @@ API_KEY = { description = "API key", providers = ["remote", "local"] }
Use per-secret `refs = { alias = { item = "..." } }` for exceptions. See
[Secret References](/concepts/references/#different-coordinates-per-provider-019)
-for precedence, import-only source aliases, and cached-route restrictions.
+for precedence, environment placeholders, import-only source aliases, and
+cached-route restrictions.
Use the CLI to manage user-level aliases:
diff --git a/docs/src/content/docs/concepts/references.mdx b/docs/src/content/docs/concepts/references.mdx
index 73d072761..3f75325cc 100644
--- a/docs/src/content/docs/concepts/references.mdx
+++ b/docs/src/content/docs/concepts/references.mdx
@@ -104,6 +104,34 @@ fallback reads, writes, and both sides of `import`; the `legacy` ref above can
therefore describe an import source without adding it to the normal read route.
Templates support `{project}`, `{profile}`, and `{key}` in every coordinate.
+### Environment placeholders {/* #environment-placeholders-021 */}
+
+
+
+Use `{env:NAME}` when a provider coordinate contains a non-secret value supplied
+by the current environment:
+
+```toml
+[providers]
+personal = {
+ uri = "awsps://developer@us-east-1",
+ ref = { item = "/apps/{project}/developers/{env:DEVELOPER_ID}/{key}" }
+}
+
+[profiles.development]
+DATABASE_PASSWORD = { description = "Personal database password", providers = ["personal"] }
+```
+
+SecretSpec reads `DEVELOPER_ID` when it expands the address. A missing or
+non-UTF-8 value fails before provider access. Expansion is one pass, so braces
+inside the environment value remain literal.
+
+Environment placeholders are for coordinates such as developer names, tenant
+IDs, and namespaces. Expanded coordinates can appear in diagnostics and audit
+records, so do not put a secret value in them. This syntax is separate from
+[composed secrets](/concepts/composed-secrets/): it does not resolve another
+declared secret and does not use `${NAME}` syntax.
+
Scoped refs deliberately key on aliases, not resolved URIs. Literal provider
URIs and bare provider names use convention naming because they have no alias
identity. Cached route aliases cannot own a template or scoped ref; configure
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index 392acc50e..64c0794c0 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -489,7 +489,7 @@ forms are accepted in the project `[providers]` and user
|-------|------|----------|-------------|
| `uri` | string | Yes (table form) | The provider URI. A bare-string alias is shorthand for `{ uri = "..." }`. |
| `credentials` | table | No | Maps a semantic [provider credential](/reference/provider-credentials/) name to its source. |
-| `ref` (0.19+) | table | No | Native-address template for this leaf alias. Coordinate strings may contain `{project}`, `{profile}`, and `{key}`. |
+| `ref` (0.19+) | table | No | Native-address template for this leaf alias. Coordinate strings may contain `{project}`, `{profile}`, `{key}`, and, in 0.21+, `{env:NAME}` for a non-secret environment value. |
Each `credentials` value is either a bare provider spec — read at the convention path for the active project and profile — or a table `{ provider = "...", ref = { ... } }` that pins the exact location with the same `ref` coordinates a secret uses.
diff --git a/secretspec/src/config.rs b/secretspec/src/config.rs
index 4671c4045..e80c62bb3 100644
--- a/secretspec/src/config.rs
+++ b/secretspec/src/config.rs
@@ -1654,9 +1654,10 @@ impl NativeAddress {
/// A provider-alias template for native secret coordinates (0.19+).
///
/// Each coordinate accepts the logical placeholders `{project}`, `{profile}`,
-/// and `{key}`. The template belongs to one provider alias, so fallback links
-/// and import endpoints can map the same logical secret into different native
-/// address shapes without sharing provider-specific coordinates.
+/// and `{key}`, plus `{env:NAME}` for non-secret process environment values.
+/// The template belongs to one provider alias, so fallback links and import
+/// endpoints can map the same logical secret into different native address
+/// shapes without sharing provider-specific coordinates.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NativeAddressTemplate {
@@ -1698,7 +1699,10 @@ impl NativeAddressTemplate {
"provider ref template coordinate `{name}` cannot be empty or whitespace"
));
}
- expand_address_template(value, "project", "profile", "KEY").map_err(|error| {
+ expand_address_template(value, "project", "profile", "KEY", &|variable| {
+ Ok(format!("env-{variable}"))
+ })
+ .map_err(|error| {
format!("invalid provider ref template coordinate `{name}`: {error}")
})?;
}
@@ -1713,7 +1717,17 @@ impl NativeAddressTemplate {
key: &str,
) -> std::result::Result {
self.validate()?;
- let expand = |value: &str| expand_address_template(value, project, profile, key);
+ let environment = |variable: &str| match std::env::var(variable) {
+ Ok(value) => Ok(value),
+ Err(std::env::VarError::NotPresent) => Err(format!(
+ "environment variable `{variable}` required by provider ref template is not set"
+ )),
+ Err(std::env::VarError::NotUnicode(_)) => Err(format!(
+ "environment variable `{variable}` required by provider ref template is not valid UTF-8"
+ )),
+ };
+ let expand =
+ |value: &str| expand_address_template(value, project, profile, key, &environment);
Ok(NativeAddress {
item: expand(&self.item)?,
field: self.field.as_deref().map(expand).transpose()?,
@@ -1748,6 +1762,7 @@ fn expand_address_template(
project: &str,
profile: &str,
key: &str,
+ environment: &impl Fn(&str) -> std::result::Result,
) -> std::result::Result {
let mut out = String::with_capacity(template.len());
let mut rest = template;
@@ -1769,16 +1784,30 @@ fn expand_address_template(
return Err(format!("template '{template}' contains an unmatched `{{`"));
};
let placeholder = &after_open[..close];
- out.push_str(match placeholder {
- "project" => project,
- "profile" => profile,
- "key" => key,
+ match placeholder {
+ "project" => out.push_str(project),
+ "profile" => out.push_str(profile),
+ "key" => out.push_str(key),
_ => {
- return Err(format!(
- "unknown placeholder `{{{placeholder}}}` in template '{template}'; expected {{project}}, {{profile}}, or {{key}}"
- ));
+ let Some(variable) = placeholder.strip_prefix("env:") else {
+ return Err(format!(
+ "unknown placeholder `{{{placeholder}}}` in template '{template}'; expected {{project}}, {{profile}}, {{key}}, or {{env:NAME}}"
+ ));
+ };
+ let mut characters = variable.chars();
+ let valid_name = characters
+ .next()
+ .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
+ && characters
+ .all(|character| character == '_' || character.is_ascii_alphanumeric());
+ if !valid_name {
+ return Err(format!(
+ "invalid environment variable name `{variable}` in template '{template}'"
+ ));
+ }
+ out.push_str(&environment(variable)?);
}
- });
+ }
rest = &after_open[close + 1..];
}
Ok(out)
@@ -4351,6 +4380,34 @@ mod provider_alias_tests {
assert_eq!(parse(&serialized), map);
}
+ #[test]
+ fn alias_ref_template_expands_non_secret_environment_values() {
+ let _environment_lock = crate::tests::scrub_resolution_env();
+ let variable = "SECRETSPEC_TEST_REF_OWNER";
+ let value = crate::tests::EnvVarGuard::set(variable, "alice_{key}");
+ let map = parse(
+ r#"remote = { uri = "keyring://", ref = { item = "{project}/{env:SECRETSPEC_TEST_REF_OWNER}/{key}" } }"#,
+ );
+ let template = map["remote"].reference_template().unwrap();
+
+ assert_eq!(
+ template
+ .expand("payments", "prod", "DATABASE_URL")
+ .unwrap()
+ .item,
+ "payments/alice_{key}/DATABASE_URL",
+ "inserted environment values must not be interpreted as placeholders"
+ );
+
+ drop(value);
+ let _missing = crate::tests::EnvVarGuard::remove(variable);
+ let error = template
+ .expand("payments", "prod", "DATABASE_URL")
+ .unwrap_err();
+ assert!(error.contains(variable), "{error}");
+ assert!(error.contains("is not set"), "{error}");
+ }
+
#[test]
fn alias_ref_template_rejects_invalid_placeholders_and_cached_aliases() {
let error = toml::from_str::>(
@@ -4359,6 +4416,17 @@ mod provider_alias_tests {
.unwrap_err();
assert!(error.to_string().contains("unknown placeholder"), "{error}");
+ let error = toml::from_str::>(
+ r#"op = { uri = "onepassword://Production", ref = { item = "{env:INVALID-NAME}" } }"#,
+ )
+ .unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("invalid environment variable name"),
+ "{error}"
+ );
+
let error = toml::from_str::>(
r#"route = { fallback = ["remote"], cache = { provider = "local", max_age = "1h" }, ref = { item = "{key}" } }"#,
)
diff --git a/secretspec/src/plan.rs b/secretspec/src/plan.rs
index 9c0d735e9..b079de587 100644
--- a/secretspec/src/plan.rs
+++ b/secretspec/src/plan.rs
@@ -627,14 +627,30 @@ impl Secrets {
.iter()
.zip(&source_route_uris)
.map(|(spec, uri)| {
- let template = self
+ let template = match self
.lookup_provider_alias_entry(spec)
.and_then(|alias| alias.reference_template())
- .map(|template| coordinate_fingerprint("ref-template", template.coordinates()))
- .unwrap_or_else(|| "convention".to_string());
- stable_fingerprint(["source-route", spec, uri, template.as_str()])
+ {
+ Some(template) => {
+ let expanded = template.expand("project", "profile", "KEY").map_err(
+ |error| {
+ SecretSpecError::ProviderOperationFailed(format!(
+ "provider alias '{spec}' could not expand its ref template: {error}"
+ ))
+ },
+ )?;
+ coordinate_fingerprint("ref-template", expanded.coordinates())
+ }
+ None => "convention".to_string(),
+ };
+ Ok(stable_fingerprint([
+ "source-route",
+ spec,
+ uri,
+ template.as_str(),
+ ]))
})
- .collect::>();
+ .collect::>>()?;
let cache_identity = self.canonical_storage_identity(&cache_uri)?;
// The cache entry lives at the same logical address the authoritative
@@ -786,7 +802,7 @@ mod tests {
use super::*;
use crate::config::{NativeAddressTemplate, ProviderAlias, ProviderCache};
use crate::error::SecretSpecError;
- use crate::tests::{global_config_with_aliases, scrub_resolution_env};
+ use crate::tests::{EnvVarGuard, global_config_with_aliases, scrub_resolution_env};
use std::collections::HashMap;
/// Build a secret with a description and optional per-secret provider chain.
@@ -1494,6 +1510,28 @@ mod tests {
);
}
+ #[test]
+ fn environment_template_changes_invalidate_the_cache() {
+ let _environment_lock = scrub_resolution_env();
+ let variable = "SECRETSPEC_TEST_REF_NAMESPACE";
+ let template = || NativeAddressTemplate {
+ item: format!("{{env:{variable}}}/{{key}}"),
+ ..Default::default()
+ };
+
+ let first_value = EnvVarGuard::set(variable, "team-a");
+ let first = template_fingerprint(template());
+ drop(first_value);
+
+ let _second_value = EnvVarGuard::set(variable, "team-b");
+ let second = template_fingerprint(template());
+
+ assert_ne!(
+ first, second,
+ "the cached route must follow the environment-backed address"
+ );
+ }
+
fn scoped_ref_cache_fingerprint(reference: NativeAddress) -> String {
let mut configured = secret(Some(vec!["route"]));
configured.refs = Some(HashMap::from([("source".to_string(), reference)]));