From f5ecf79cf18779d39698e1739cd7c4dba42a2ee7 Mon Sep 17 00:00:00 2001 From: sidux Date: Mon, 31 Aug 2026 16:04:15 +0200 Subject: [PATCH] feat(config): Interpolate environment values in ref templates Allow provider aliases to use explicit {env:NAME} placeholders for non-secret native coordinates. Include expanded values in cache fingerprints so cached routes follow environment changes. --- CHANGELOG.md | 4 + docs/src/content/docs/concepts/providers.mdx | 8 +- docs/src/content/docs/concepts/references.mdx | 28 ++++++ .../content/docs/reference/configuration.mdx | 2 +- secretspec/src/config.rs | 94 ++++++++++++++++--- secretspec/src/plan.rs | 50 ++++++++-- 6 files changed, 163 insertions(+), 23 deletions(-) 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)]));