Skip to content

Commit 83131d7

Browse files
authored
feat(cli): add --secret-material-env to provider refresh configure (#2178)
* feat(cli): add --secret-material-env to provider refresh configure * feat(cli): reject duplicate secret material keys Signed-off-by: Hung Le <hple@nvidia.com> --------- Signed-off-by: Hung Le <hple@nvidia.com>
1 parent 709aa0f commit 83131d7

4 files changed

Lines changed: 283 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: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4007,6 +4007,47 @@ 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+
if map.contains_key(key) {
4041+
return Err(miette::miette!(
4042+
"--secret-material-env key '{key}' supplied more than once"
4043+
));
4044+
}
4045+
map.insert(key.to_string(), value);
4046+
}
4047+
4048+
Ok(map)
4049+
}
4050+
40104051
fn is_valid_env_name(key: &str) -> bool {
40114052
let mut bytes = key.bytes();
40124053
let Some(first) = bytes.next() else {
@@ -5282,6 +5323,7 @@ pub struct ProviderRefreshConfigInput<'a> {
52825323
pub credential_key: &'a str,
52835324
pub strategy: &'a str,
52845325
pub material: &'a [String],
5326+
pub secret_material_env: &'a [String],
52855327
pub secret_material_keys: &'a [String],
52865328
pub credential_expires_at_ms: Option<i64>,
52875329
}
@@ -5292,15 +5334,29 @@ pub async fn provider_refresh_config(
52925334
tls: &TlsOptions,
52935335
) -> Result<()> {
52945336
let strategy = provider_refresh_strategy(input.strategy)?;
5295-
let material = parse_key_value_pairs(input.material, "--material")?;
5337+
let mut material = parse_key_value_pairs(input.material, "--material")?;
5338+
let mut secret_material_keys = input.secret_material_keys.to_vec();
5339+
// Env-resolved secrets are auto-marked secret; duplicate keys are an
5340+
// error rather than a precedence order.
5341+
for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? {
5342+
if material.contains_key(&key) {
5343+
return Err(miette!(
5344+
"duplicate material key '{key}': supplied via both --material and --secret-material-env"
5345+
));
5346+
}
5347+
if !secret_material_keys.contains(&key) {
5348+
secret_material_keys.push(key.clone());
5349+
}
5350+
material.insert(key, value);
5351+
}
52965352
let mut client = grpc_client(server, tls).await?;
52975353
let status = client
52985354
.configure_provider_refresh(ConfigureProviderRefreshRequest {
52995355
provider: input.name.to_string(),
53005356
credential_key: input.credential_key.to_string(),
53015357
strategy: strategy as i32,
53025358
material,
5303-
secret_material_keys: input.secret_material_keys.to_vec(),
5359+
secret_material_keys,
53045360
expires_at_ms: input.credential_expires_at_ms,
53055361
})
53065362
.await
@@ -7845,11 +7901,12 @@ mod tests {
78457901
gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle,
78467902
inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
78477903
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,
7904+
parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
7905+
plaintext_gateway_is_remote, progress_step_from_metadata,
7906+
provider_profile_allows_empty_credentials, provisioning_timeout_message,
7907+
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7908+
sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7909+
service_url_for_gateway,
78537910
};
78547911
use crate::TEST_ENV_LOCK;
78557912
use hyper::StatusCode;
@@ -7993,6 +8050,73 @@ mod tests {
79938050
));
79948051
}
79958052

8053+
#[test]
8054+
fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() {
8055+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material");
8056+
8057+
let parsed =
8058+
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()])
8059+
.expect("parse");
8060+
assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string()));
8061+
}
8062+
8063+
#[test]
8064+
fn parse_secret_material_env_pairs_defaults_env_name_to_key() {
8065+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material");
8066+
8067+
let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()])
8068+
.expect("parse");
8069+
assert_eq!(
8070+
parsed.get("NAV_PARSE_SME_KEY_ONLY"),
8071+
Some(&"key-only-material".to_string())
8072+
);
8073+
}
8074+
8075+
#[test]
8076+
fn parse_secret_material_env_pairs_rejects_missing_environment() {
8077+
let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING");
8078+
8079+
let err =
8080+
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()])
8081+
.expect_err("missing env should error");
8082+
assert!(err.to_string().contains(
8083+
"requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
8084+
));
8085+
}
8086+
8087+
#[test]
8088+
fn parse_secret_material_env_pairs_rejects_empty_environment_value() {
8089+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " ");
8090+
8091+
let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()])
8092+
.expect_err("blank env should error");
8093+
assert!(err.to_string().contains(
8094+
"requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
8095+
));
8096+
}
8097+
8098+
#[test]
8099+
fn parse_secret_material_env_pairs_rejects_empty_key() {
8100+
let err = parse_secret_material_env_pairs(&["=NAV_PARSE_SME_NO_KEY".to_string()])
8101+
.expect_err("empty key should error");
8102+
assert!(err.to_string().contains("key cannot be empty"));
8103+
}
8104+
8105+
#[test]
8106+
fn parse_secret_material_env_pairs_rejects_duplicate_keys() {
8107+
let _guard = EnvVarGuard::set("NAV_PARSE_SME_DUP", "value");
8108+
8109+
let err = parse_secret_material_env_pairs(&[
8110+
"private_key=NAV_PARSE_SME_DUP".to_string(),
8111+
"private_key=NAV_PARSE_SME_DUP".to_string(),
8112+
])
8113+
.expect_err("duplicate key should error");
8114+
assert!(
8115+
err.to_string()
8116+
.contains("key 'private_key' supplied more than once")
8117+
);
8118+
}
8119+
79968120
#[test]
79978121
fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() {
79988122
let parsed = parse_credential_expiry_pairs(&[

0 commit comments

Comments
 (0)