Skip to content

Commit cc785be

Browse files
committed
feat(cli): add --secret-material-env to provider refresh configure
1 parent f7aa3aa commit cc785be

4 files changed

Lines changed: 230 additions & 22 deletions

File tree

crates/openshell-cli/src/main.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,11 @@ enum ProviderRefreshCommands {
898898
#[arg(long = "material", value_name = "KEY=VALUE")]
899899
material: Vec<String>,
900900

901+
/// Secret refresh material resolved from the CLI environment
902+
/// (`ENVVAR` defaults to `KEY`); `KEY` is auto-marked secret.
903+
#[arg(long = "secret-material-env", value_name = "KEY[=ENVVAR]")]
904+
secret_material_env: Vec<String>,
905+
901906
/// Material keys that are secret and must not be exposed.
902907
#[arg(long = "secret-material-key", value_name = "KEY")]
903908
secret_material_keys: Vec<String>,
@@ -2922,6 +2927,7 @@ async fn main() -> Result<()> {
29222927
credential_key,
29232928
strategy,
29242929
material,
2930+
secret_material_env,
29252931
secret_material_keys,
29262932
credential_expires_at,
29272933
} => {
@@ -2932,6 +2938,7 @@ async fn main() -> Result<()> {
29322938
credential_key: &credential_key,
29332939
strategy: strategy.as_str(),
29342940
material: &material,
2941+
secret_material_env: &secret_material_env,
29352942
secret_material_keys: &secret_material_keys,
29362943
credential_expires_at_ms: credential_expires_at,
29372944
},
@@ -4189,6 +4196,8 @@ mod tests {
41894196
"oauth2-client-credentials",
41904197
"--material",
41914198
"tenant_id=abc",
4199+
"--secret-material-env",
4200+
"client_secret=GRAPH_CLIENT_SECRET",
41924201
"--secret-material-key",
41934202
"client_secret",
41944203
"--credential-expires-at",
@@ -4202,10 +4211,11 @@ mod tests {
42024211
ProviderRefreshCommands::Configure {
42034212
strategy: CliProviderRefreshStrategy::Oauth2ClientCredentials,
42044213
credential_expires_at: Some(1_767_225_600_000),
4214+
ref secret_material_env,
42054215
..
42064216
}
42074217
))
4208-
})
4218+
}) if secret_material_env == &["client_secret=GRAPH_CLIENT_SECRET".to_string()]
42094219
));
42104220

42114221
let rotate = Cli::try_parse_from([

crates/openshell-cli/src/run.rs

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4007,6 +4007,42 @@ pub fn parse_env_pairs(items: &[String]) -> Result<HashMap<String, String>> {
40074007
Ok(map)
40084008
}
40094009

4010+
/// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process
4011+
/// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv.
4012+
pub fn parse_secret_material_env_pairs(items: &[String]) -> Result<HashMap<String, String>> {
4013+
let mut map = HashMap::new();
4014+
4015+
for item in items {
4016+
let (key, env_name) = match item.split_once('=') {
4017+
Some((key, env_name)) => (key.trim(), env_name.trim()),
4018+
None => (item.trim(), item.trim()),
4019+
};
4020+
if key.is_empty() {
4021+
return Err(miette::miette!("--secret-material-env key cannot be empty"));
4022+
}
4023+
if env_name.is_empty() {
4024+
return Err(miette::miette!(
4025+
"--secret-material-env {key} names an empty environment variable"
4026+
));
4027+
}
4028+
4029+
let value = std::env::var(env_name).map_err(|_| {
4030+
miette::miette!(
4031+
"--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
4032+
)
4033+
})?;
4034+
if value.trim().is_empty() {
4035+
return Err(miette::miette!(
4036+
"--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
4037+
));
4038+
}
4039+
4040+
map.insert(key.to_string(), value);
4041+
}
4042+
4043+
Ok(map)
4044+
}
4045+
40104046
fn is_valid_env_name(key: &str) -> bool {
40114047
let mut bytes = key.bytes();
40124048
let Some(first) = bytes.next() else {
@@ -5282,6 +5318,7 @@ pub struct ProviderRefreshConfigInput<'a> {
52825318
pub credential_key: &'a str,
52835319
pub strategy: &'a str,
52845320
pub material: &'a [String],
5321+
pub secret_material_env: &'a [String],
52855322
pub secret_material_keys: &'a [String],
52865323
pub credential_expires_at_ms: Option<i64>,
52875324
}
@@ -5292,15 +5329,23 @@ pub async fn provider_refresh_config(
52925329
tls: &TlsOptions,
52935330
) -> Result<()> {
52945331
let strategy = provider_refresh_strategy(input.strategy)?;
5295-
let material = parse_key_value_pairs(input.material, "--material")?;
5332+
let mut material = parse_key_value_pairs(input.material, "--material")?;
5333+
let mut secret_material_keys = input.secret_material_keys.to_vec();
5334+
// Env-resolved secrets override `--material` duplicates and are auto-marked secret.
5335+
for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? {
5336+
if !secret_material_keys.contains(&key) {
5337+
secret_material_keys.push(key.clone());
5338+
}
5339+
material.insert(key, value);
5340+
}
52965341
let mut client = grpc_client(server, tls).await?;
52975342
let status = client
52985343
.configure_provider_refresh(ConfigureProviderRefreshRequest {
52995344
provider: input.name.to_string(),
53005345
credential_key: input.credential_key.to_string(),
53015346
strategy: strategy as i32,
53025347
material,
5303-
secret_material_keys: input.secret_material_keys.to_vec(),
5348+
secret_material_keys,
53045349
expires_at_ms: input.credential_expires_at_ms,
53055350
})
53065351
.await
@@ -7845,11 +7890,12 @@ mod tests {
78457890
gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle,
78467891
inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
78477892
parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs,
7848-
parse_credential_pairs, parse_driver_config_json, plaintext_gateway_is_remote,
7849-
progress_step_from_metadata, provider_profile_allows_empty_credentials,
7850-
provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
7851-
refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan,
7852-
service_expose_status_error, service_url_for_gateway,
7893+
parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
7894+
plaintext_gateway_is_remote, progress_step_from_metadata,
7895+
provider_profile_allows_empty_credentials, provisioning_timeout_message,
7896+
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7897+
sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7898+
service_url_for_gateway,
78537899
};
78547900
use crate::TEST_ENV_LOCK;
78557901
use hyper::StatusCode;
@@ -7993,6 +8039,58 @@ mod tests {
79938039
));
79948040
}
79958041

8042+
#[test]
8043+
fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() {
8044+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material");
8045+
8046+
let parsed =
8047+
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()])
8048+
.expect("parse");
8049+
assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string()));
8050+
}
8051+
8052+
#[test]
8053+
fn parse_secret_material_env_pairs_defaults_env_name_to_key() {
8054+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material");
8055+
8056+
let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()])
8057+
.expect("parse");
8058+
assert_eq!(
8059+
parsed.get("NAV_PARSE_SME_KEY_ONLY"),
8060+
Some(&"key-only-material".to_string())
8061+
);
8062+
}
8063+
8064+
#[test]
8065+
fn parse_secret_material_env_pairs_rejects_missing_environment() {
8066+
let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING");
8067+
8068+
let err =
8069+
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()])
8070+
.expect_err("missing env should error");
8071+
assert!(err.to_string().contains(
8072+
"requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
8073+
));
8074+
}
8075+
8076+
#[test]
8077+
fn parse_secret_material_env_pairs_rejects_empty_environment_value() {
8078+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " ");
8079+
8080+
let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()])
8081+
.expect_err("blank env should error");
8082+
assert!(err.to_string().contains(
8083+
"requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
8084+
));
8085+
}
8086+
8087+
#[test]
8088+
fn parse_secret_material_env_pairs_rejects_empty_key() {
8089+
let err = parse_secret_material_env_pairs(&["=NAV_PARSE_SME_NO_KEY".to_string()])
8090+
.expect_err("empty key should error");
8091+
assert!(err.to_string().contains("key cannot be empty"));
8092+
}
8093+
79968094
#[test]
79978095
fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() {
79988096
let parsed = parse_credential_expiry_pairs(&[

crates/openshell-cli/tests/provider_commands_integration.rs

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ enum ProviderRefreshRequestLog {
6262
Configure {
6363
provider_name: String,
6464
credential_key: String,
65+
material: HashMap<String, String>,
66+
secret_material_keys: Vec<String>,
6567
expires_at_ms: Option<i64>,
6668
},
6769
Rotate {
@@ -661,6 +663,8 @@ impl OpenShell for TestOpenShell {
661663
.push(ProviderRefreshRequestLog::Configure {
662664
provider_name: request.provider.clone(),
663665
credential_key: request.credential_key.clone(),
666+
material: request.material.clone(),
667+
secret_material_keys: request.secret_material_keys.clone(),
664668
expires_at_ms: request.expires_at_ms,
665669
});
666670
let configure_failure = self
@@ -1187,6 +1191,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
11871191
credential_key: "MS_GRAPH_ACCESS_TOKEN",
11881192
strategy: "oauth2_client_credentials",
11891193
material: &["tenant_id=tenant".to_string()],
1194+
secret_material_env: &[],
11901195
secret_material_keys: &["client_secret".to_string()],
11911196
credential_expires_at_ms: Some(1_767_225_600_000),
11921197
},
@@ -1216,6 +1221,8 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
12161221
ProviderRefreshRequestLog::Configure {
12171222
provider_name: "my-graph".to_string(),
12181223
credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(),
1224+
material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]),
1225+
secret_material_keys: vec!["client_secret".to_string()],
12191226
expires_at_ms: Some(1_767_225_600_000),
12201227
},
12211228
ProviderRefreshRequestLog::Status {
@@ -1234,6 +1241,91 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
12341241
);
12351242
}
12361243

1244+
#[tokio::test]
1245+
async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() {
1246+
let ts = run_server().await;
1247+
1248+
run::provider_create(
1249+
&ts.endpoint,
1250+
"gc-bridge",
1251+
"outlook",
1252+
false,
1253+
&["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()],
1254+
false,
1255+
&[],
1256+
&ts.tls,
1257+
)
1258+
.await
1259+
.expect("provider create");
1260+
1261+
// The env value overrides the `--material` duplicate and is auto-marked secret.
1262+
let guard = EnvVarGuard::set(&[("OPENSHELL_ITEST_SME_PRIVATE_KEY", "pem-from-env")]);
1263+
run::provider_refresh_config(
1264+
&ts.endpoint,
1265+
run::ProviderRefreshConfigInput {
1266+
name: "gc-bridge",
1267+
credential_key: "GOOGLE_CHAT_ACCESS_TOKEN",
1268+
strategy: "google_service_account_jwt",
1269+
material: &[
1270+
"client_email=bot@p.iam.gserviceaccount.com".to_string(),
1271+
"private_key=argv-value-must-be-overridden".to_string(),
1272+
],
1273+
secret_material_env: &["private_key=OPENSHELL_ITEST_SME_PRIVATE_KEY".to_string()],
1274+
secret_material_keys: &[],
1275+
credential_expires_at_ms: None,
1276+
},
1277+
&ts.tls,
1278+
)
1279+
.await
1280+
.expect("provider refresh configure");
1281+
drop(guard);
1282+
1283+
let requests = ts.state.refresh_requests.lock().await.clone();
1284+
assert_eq!(
1285+
requests,
1286+
vec![ProviderRefreshRequestLog::Configure {
1287+
provider_name: "gc-bridge".to_string(),
1288+
credential_key: "GOOGLE_CHAT_ACCESS_TOKEN".to_string(),
1289+
material: HashMap::from([
1290+
(
1291+
"client_email".to_string(),
1292+
"bot@p.iam.gserviceaccount.com".to_string()
1293+
),
1294+
("private_key".to_string(), "pem-from-env".to_string()),
1295+
]),
1296+
secret_material_keys: vec!["private_key".to_string()],
1297+
expires_at_ms: None,
1298+
}]
1299+
);
1300+
}
1301+
1302+
#[tokio::test]
1303+
async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_unset() {
1304+
let ts = run_server().await;
1305+
1306+
let err = run::provider_refresh_config(
1307+
&ts.endpoint,
1308+
run::ProviderRefreshConfigInput {
1309+
name: "gc-bridge",
1310+
credential_key: "GOOGLE_CHAT_ACCESS_TOKEN",
1311+
strategy: "google_service_account_jwt",
1312+
material: &[],
1313+
secret_material_env: &["private_key=OPENSHELL_ITEST_SME_DEFINITELY_UNSET".to_string()],
1314+
secret_material_keys: &[],
1315+
credential_expires_at_ms: None,
1316+
},
1317+
&ts.tls,
1318+
)
1319+
.await
1320+
.expect_err("unset env should fail before any request is sent");
1321+
1322+
assert!(err.to_string().contains(
1323+
"requires local env var 'OPENSHELL_ITEST_SME_DEFINITELY_UNSET' to be set to a non-empty value"
1324+
));
1325+
// Fails closed on the client side: nothing reached the gateway.
1326+
assert!(ts.state.refresh_requests.lock().await.is_empty());
1327+
}
1328+
12371329
#[tokio::test]
12381330
async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() {
12391331
let ts = run_server().await;
@@ -2197,14 +2289,15 @@ async fn provider_create_from_gcloud_adc_happy_path() {
21972289
2,
21982290
"expected configure + rotate refresh requests"
21992291
);
2200-
assert_eq!(
2201-
requests[0],
2292+
assert!(matches!(
2293+
&requests[0],
22022294
ProviderRefreshRequestLog::Configure {
2203-
provider_name: "my-vertex".to_string(),
2204-
credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(),
2295+
provider_name,
2296+
credential_key,
22052297
expires_at_ms: None,
2206-
}
2207-
);
2298+
..
2299+
} if provider_name == "my-vertex" && credential_key == "GOOGLE_VERTEX_AI_TOKEN"
2300+
));
22082301
assert_eq!(
22092302
requests[1],
22102303
ProviderRefreshRequestLog::Rotate {
@@ -2577,14 +2670,15 @@ async fn provider_create_from_gcloud_adc_with_config_keys() {
25772670
2,
25782671
"exactly one configure call and one rotate call expected"
25792672
);
2580-
assert_eq!(
2581-
refresh_requests[0],
2673+
assert!(matches!(
2674+
&refresh_requests[0],
25822675
ProviderRefreshRequestLog::Configure {
2583-
provider_name: "vertex-with-config".to_string(),
2584-
credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(),
2676+
provider_name,
2677+
credential_key,
25852678
expires_at_ms: None,
2586-
}
2587-
);
2679+
..
2680+
} if provider_name == "vertex-with-config" && credential_key == "GOOGLE_VERTEX_AI_TOKEN"
2681+
));
25882682
assert_eq!(
25892683
refresh_requests[1],
25902684
ProviderRefreshRequestLog::Rotate {

docs/sandboxes/manage-providers.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,17 @@ openshell provider refresh configure my-graph \
147147
--strategy oauth2-client-credentials \
148148
--material tenant_id="$TENANT_ID" \
149149
--material client_id="$CLIENT_ID" \
150-
--material client_secret="$CLIENT_SECRET" \
151-
--secret-material-key client_secret \
150+
--secret-material-env client_secret=CLIENT_SECRET \
152151
--credential-expires-at 1767225600000
153152
```
154153

154+
Pass secret material with `--secret-material-env KEY[=ENVVAR]` (`ENVVAR`
155+
defaults to `KEY`): the CLI reads the value from its own environment, so the
156+
secret never appears in the host process table the way an expanded
157+
`--material KEY="$VALUE"` argument would, and `KEY` is automatically marked
158+
secret. Keep non-secret material on `--material`; `--secret-material-key KEY`
159+
still marks a key supplied through `--material` as secret.
160+
155161
Check refresh status:
156162

157163
```shell

0 commit comments

Comments
 (0)