Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/concepts/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,9 @@ DATABASE_URL = { description = "Production database", providers = ["onepassword:
<VersionCompatibility version="0.19" />

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]
Expand All @@ -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:

Expand Down
28 changes: 28 additions & 0 deletions docs/src/content/docs/concepts/references.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}

<VersionCompatibility version="0.21" />

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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
94 changes: 81 additions & 13 deletions secretspec/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}")
})?;
}
Expand All @@ -1713,7 +1717,17 @@ impl NativeAddressTemplate {
key: &str,
) -> std::result::Result<NativeAddress, String> {
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()?,
Expand Down Expand Up @@ -1748,6 +1762,7 @@ fn expand_address_template(
project: &str,
profile: &str,
key: &str,
environment: &impl Fn(&str) -> std::result::Result<String, String>,
) -> std::result::Result<String, String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
Expand All @@ -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)
Expand Down Expand Up @@ -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::<HashMap<String, ProviderAlias>>(
Expand All @@ -4359,6 +4416,17 @@ mod provider_alias_tests {
.unwrap_err();
assert!(error.to_string().contains("unknown placeholder"), "{error}");

let error = toml::from_str::<HashMap<String, ProviderAlias>>(
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::<HashMap<String, ProviderAlias>>(
r#"route = { fallback = ["remote"], cache = { provider = "local", max_age = "1h" }, ref = { item = "{key}" } }"#,
)
Expand Down
50 changes: 44 additions & 6 deletions secretspec/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
.collect::<Result<Vec<_>>>()?;
let cache_identity = self.canonical_storage_identity(&cache_uri)?;

// The cache entry lives at the same logical address the authoritative
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)]));
Expand Down