Skip to content

Commit f27ff15

Browse files
authored
fix(providers): reserve credential placeholder revisions (#2049)
* fix(providers): reserve credential placeholder revisions Signed-off-by: John Myers <johntmyers@users.noreply.github.com> * fix(providers): share placeholder namespace parser Signed-off-by: John Myers <johntmyers@users.noreply.github.com> * test(providers): cover non-revision env key Signed-off-by: John Myers <johntmyers@users.noreply.github.com> --------- Signed-off-by: John Myers <johntmyers@users.noreply.github.com> Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent a226806 commit f27ff15

5 files changed

Lines changed: 174 additions & 5 deletions

File tree

architecture/sandbox.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy
9595
when policy allows the target endpoint. For GCP providers, a loopback metadata
9696
server inside the network namespace serves placeholders to SDKs that bypass the
9797
proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be
98-
logged in OCSF or plain tracing output.
98+
logged in OCSF or plain tracing output. The supervisor uses revision-scoped
99+
placeholders for rotating provider credentials; provider environment keys
100+
beginning with `v<digits>_` are reserved for that placeholder namespace.
99101

100102
Provider profiles can also declare dynamic token grants. For matching HTTP
101103
endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API,

crates/openshell-core/src/provider_credentials.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,4 +593,54 @@ mod tests {
593593
"suppressed key must not reappear after install_environment"
594594
);
595595
}
596+
597+
#[test]
598+
fn stale_generation_falls_back_to_current_credential_after_retention_window() {
599+
let state = ProviderCredentialState::from_environment(
600+
10,
601+
HashMap::from([("GITHUB_TOKEN".to_string(), "old".to_string())]),
602+
HashMap::new(),
603+
HashMap::new(),
604+
);
605+
606+
for revision in 11..20 {
607+
state.install_environment(
608+
revision,
609+
HashMap::from([("GITHUB_TOKEN".to_string(), format!("new-{revision}"))]),
610+
HashMap::new(),
611+
HashMap::new(),
612+
);
613+
}
614+
615+
let resolver = state.resolver().expect("resolver");
616+
assert_eq!(
617+
resolver.resolve_placeholder("openshell:resolve:env:v10_GITHUB_TOKEN"),
618+
Some("new-19")
619+
);
620+
}
621+
622+
#[test]
623+
fn stale_removed_generation_fails_closed_after_retention_window() {
624+
let state = ProviderCredentialState::from_environment(
625+
10,
626+
HashMap::from([("GITHUB_TOKEN".to_string(), "old".to_string())]),
627+
HashMap::new(),
628+
HashMap::new(),
629+
);
630+
631+
for revision in 11..20 {
632+
state.install_environment(
633+
revision,
634+
HashMap::from([("OTHER_TOKEN".to_string(), format!("other-{revision}"))]),
635+
HashMap::new(),
636+
HashMap::new(),
637+
);
638+
}
639+
640+
let resolver = state.resolver().expect("retained resolver");
641+
assert_eq!(
642+
resolver.resolve_placeholder("openshell:resolve:env:v10_GITHUB_TOKEN"),
643+
None
644+
);
645+
}
596646
}

crates/openshell-core/src/secrets.rs

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ impl SecretResolver {
177177
let mut by_placeholder = HashMap::with_capacity(provider_env.len());
178178

179179
for (key, value) in provider_env {
180+
if uses_reserved_revision_namespace(&key) {
181+
tracing::warn!(
182+
provider_env_key = %key,
183+
"skipping provider credential env var in reserved placeholder namespace"
184+
);
185+
continue;
186+
}
180187
let placeholder = placeholder_for_env_key_for_revision(&key, revision);
181188
let secret = SecretValue {
182189
value,
@@ -192,7 +199,11 @@ impl SecretResolver {
192199
}
193200
}
194201

195-
(child_env, Some(Self { by_placeholder }))
202+
if by_placeholder.is_empty() {
203+
(child_env, None)
204+
} else {
205+
(child_env, Some(Self { by_placeholder }))
206+
}
196207
}
197208

198209
pub fn merge<'a>(resolvers: impl IntoIterator<Item = &'a Self>) -> Option<Self> {
@@ -215,7 +226,10 @@ impl SecretResolver {
215226
let secret = if let Some(secret) = self.by_placeholder.get(value) {
216227
secret
217228
} else {
218-
let key = alias_env_key(value)?;
229+
// Once an old generation ages out, the revision number is only a
230+
// namespace marker. Fall back by key to the current credential so
231+
// long-running child processes survive provider credential refresh.
232+
let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?;
219233
let canonical = placeholder_for_env_key(key);
220234
self.by_placeholder.get(&canonical)?
221235
};
@@ -475,6 +489,29 @@ fn alias_env_key(token: &str) -> Option<&str> {
475489
(key_end == token.len() && key_end > key_start).then_some(&token[key_start..key_end])
476490
}
477491

492+
fn revisioned_placeholder_env_key(token: &str) -> Option<&str> {
493+
let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?;
494+
let (_, key) = split_revisioned_env_key(suffix)?;
495+
Some(key)
496+
}
497+
498+
pub fn uses_reserved_revision_namespace(key: &str) -> bool {
499+
split_revisioned_env_key(key).is_some()
500+
}
501+
502+
fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> {
503+
let suffix = key.strip_prefix('v')?;
504+
let (revision, env_key) = suffix.split_once('_')?;
505+
if revision.is_empty()
506+
|| !revision.bytes().all(|b| b.is_ascii_digit())
507+
|| env_key.is_empty()
508+
|| !env_key.bytes().all(is_env_key_char)
509+
{
510+
return None;
511+
}
512+
Some((revision, env_key))
513+
}
514+
478515
fn token_boundary_ok(text: &str, abs_start: usize, token_end: usize, token: &str) -> bool {
479516
if token.starts_with(PLACEHOLDER_PREFIX) {
480517
return token_end == text.len()
@@ -1050,6 +1087,28 @@ mod tests {
10501087
);
10511088
}
10521089

1090+
#[test]
1091+
fn provider_env_rejects_revision_namespace_keys() {
1092+
let (child_env, resolver) = SecretResolver::from_provider_env(
1093+
[("v10_GITHUB_TOKEN".to_string(), "ambiguous".to_string())]
1094+
.into_iter()
1095+
.collect(),
1096+
);
1097+
1098+
assert!(child_env.is_empty());
1099+
assert!(resolver.is_none());
1100+
}
1101+
1102+
#[test]
1103+
fn reserved_revision_namespace_requires_version_and_key() {
1104+
assert!(uses_reserved_revision_namespace("v10_GITHUB_TOKEN"));
1105+
assert!(uses_reserved_revision_namespace("v999999_very_unlikely"));
1106+
assert!(!uses_reserved_revision_namespace("v_GITHUB_TOKEN"));
1107+
assert!(!uses_reserved_revision_namespace("v10_"));
1108+
assert!(!uses_reserved_revision_namespace("very_unlikely"));
1109+
assert!(!uses_reserved_revision_namespace("GITHUB_TOKEN"));
1110+
}
1111+
10531112
#[test]
10541113
fn rewrites_exact_placeholder_header_values() {
10551114
let (_, resolver) = SecretResolver::from_provider_env(
@@ -1083,6 +1142,49 @@ mod tests {
10831142
);
10841143
}
10851144

1145+
#[test]
1146+
fn rewrites_stale_revisioned_bearer_placeholder_to_current_alias() {
1147+
let (_, resolver) = SecretResolver::from_provider_env_for_revision_with_current_aliases(
1148+
[("GITHUB_TOKEN".to_string(), "ghp-current".to_string())]
1149+
.into_iter()
1150+
.collect(),
1151+
HashMap::new(),
1152+
42,
1153+
true,
1154+
);
1155+
let resolver = resolver.expect("resolver");
1156+
1157+
assert_eq!(
1158+
rewrite_header_line_checked(
1159+
"Authorization: Bearer openshell:resolve:env:v10_GITHUB_TOKEN",
1160+
&resolver,
1161+
)
1162+
.expect("stale revision should fall back to current alias"),
1163+
"Authorization: Bearer ghp-current"
1164+
);
1165+
}
1166+
1167+
#[test]
1168+
fn stale_revisioned_placeholder_fails_when_key_is_unknown() {
1169+
let (_, resolver) = SecretResolver::from_provider_env_for_revision_with_current_aliases(
1170+
[("GITHUB_TOKEN".to_string(), "ghp-current".to_string())]
1171+
.into_iter()
1172+
.collect(),
1173+
HashMap::new(),
1174+
42,
1175+
true,
1176+
);
1177+
let resolver = resolver.expect("resolver");
1178+
1179+
assert!(
1180+
rewrite_header_line_checked(
1181+
"Authorization: Bearer openshell:resolve:env:v999999_very_unlikely",
1182+
&resolver,
1183+
)
1184+
.is_err()
1185+
);
1186+
}
1187+
10861188
#[test]
10871189
fn rewrites_provider_shaped_alias_header_values() {
10881190
let (_, resolver) = SecretResolver::from_provider_env(

crates/openshell-providers/src/profiles.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use openshell_core::proto::{
1111
ProviderCredentialRefreshMaterial, ProviderCredentialRefreshStrategy, ProviderProfile,
1212
ProviderProfileCategory, ProviderProfileCredential, ProviderProfileDiscovery,
1313
};
14+
use openshell_core::secrets::uses_reserved_revision_namespace;
1415
use serde::ser::SerializeStruct;
1516
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
1617
use std::collections::{HashMap, HashSet};
@@ -1167,6 +1168,15 @@ pub fn validate_profile_set(
11671168
"credentials.env_vars",
11681169
"credential env var must not be empty",
11691170
));
1171+
} else if uses_reserved_revision_namespace(env_var.trim()) {
1172+
diagnostics.push(ProfileValidationDiagnostic::error(
1173+
source,
1174+
profile_id,
1175+
"credentials.env_vars",
1176+
format!(
1177+
"credential env var '{env_var}' uses reserved OpenShell placeholder revision namespace"
1178+
),
1179+
));
11701180
} else if !env_vars.insert(env_var.trim().to_string()) {
11711181
diagnostics.push(ProfileValidationDiagnostic::error(
11721182
source,
@@ -2526,7 +2536,7 @@ credentials:
25262536
env_vars: [BROKEN_TOKEN]
25272537
auth_style: query
25282538
- name: api_key
2529-
env_vars: [BROKEN_TOKEN, ""]
2539+
env_vars: [BROKEN_TOKEN, "", v10_GITHUB_TOKEN]
25302540
auth_style: unknown
25312541
- name: path_key
25322542
env_vars: [PATH_TOKEN]
@@ -2554,6 +2564,11 @@ binaries: ["", /usr/bin/broken]
25542564
assert!(messages.contains(&"duplicate credential name: api_key"));
25552565
assert!(messages.contains(&"duplicate credential env var 'BROKEN_TOKEN'"));
25562566
assert!(messages.contains(&"credential env var must not be empty"));
2567+
assert!(
2568+
messages.iter().any(
2569+
|message| message.contains("reserved OpenShell placeholder revision namespace")
2570+
)
2571+
);
25572572
assert!(messages.contains(&"query_param is required for query auth"));
25582573
assert!(messages.contains(&"path_template is required for path auth"));
25592574
assert!(messages.iter().any(|message| {

docs/sandboxes/providers-v2.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ binaries:
273273

274274
`category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum.
275275

276-
`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement.
276+
`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v<digits>_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders.
277277

278278
`discovery` controls what `--from-existing` scans when
279279
`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a

0 commit comments

Comments
 (0)