Skip to content

Commit 033803b

Browse files
committed
feat(providersv2): inject static auth headers from v2 provider profiles
Signed-off-by: Calum Murray <cmurray@redhat.com>
1 parent 1ca23bc commit 033803b

8 files changed

Lines changed: 1054 additions & 151 deletions

File tree

crates/openshell-providers/src/profiles.rs

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,16 @@ pub fn validate_profile_set(
12401240
message,
12411241
));
12421242
}
1243+
if credential.token_grant.is_none()
1244+
&& let Err(message) = validate_static_credential_header_name(credential)
1245+
{
1246+
diagnostics.push(ProfileValidationDiagnostic::error(
1247+
source,
1248+
profile_id,
1249+
"credentials.header_name",
1250+
message,
1251+
));
1252+
}
12431253
}
12441254

12451255
for (index, endpoint) in profile.endpoints.iter().enumerate() {
@@ -1542,6 +1552,19 @@ fn validate_token_grant_header_name(credential: &CredentialProfile) -> Result<()
15421552
"" | "bearer" | "header" => credential.header_name.trim(),
15431553
_ => return Ok(()),
15441554
};
1555+
validate_credential_header_name(header_name, "token_grant")
1556+
}
1557+
1558+
fn validate_static_credential_header_name(credential: &CredentialProfile) -> Result<(), String> {
1559+
let header_name = match credential.auth_style.trim().to_ascii_lowercase().as_str() {
1560+
"bearer" if credential.header_name.trim().is_empty() => "Authorization",
1561+
"bearer" | "header" => credential.header_name.trim(),
1562+
_ => return Ok(()),
1563+
};
1564+
validate_credential_header_name(header_name, "credential")
1565+
}
1566+
1567+
fn validate_credential_header_name(header_name: &str, label: &str) -> Result<(), String> {
15451568
if header_name.is_empty() {
15461569
return Ok(());
15471570
}
@@ -1566,13 +1589,14 @@ fn validate_token_grant_header_name(credential: &CredentialProfile) -> Result<()
15661589
)
15671590
});
15681591
if !valid {
1569-
return Err("token_grant header_name is not a valid HTTP header name".to_string());
1592+
return Err(format!(
1593+
"{label} header_name is not a valid HTTP header name"
1594+
));
15701595
}
15711596
match header_name.to_ascii_lowercase().as_str() {
1572-
"host" | "content-length" | "transfer-encoding" | "connection" => Err(
1573-
"token_grant header_name may not override HTTP framing or connection headers"
1574-
.to_string(),
1575-
),
1597+
"host" | "content-length" | "transfer-encoding" | "connection" => Err(format!(
1598+
"{label} header_name may not override HTTP framing or connection headers"
1599+
)),
15761600
_ => Ok(()),
15771601
}
15781602
}
@@ -2166,6 +2190,93 @@ credentials:
21662190
);
21672191
}
21682192

2193+
#[test]
2194+
fn validate_profile_set_rejects_static_credential_framing_header_name() {
2195+
let profile = parse_profile_yaml(
2196+
r"
2197+
id: framing-header-static
2198+
display_name: Framing Header Static
2199+
credentials:
2200+
- name: api_token
2201+
env_vars: [API_TOKEN]
2202+
auth_style: header
2203+
header_name: Host
2204+
",
2205+
)
2206+
.expect("profile should parse");
2207+
2208+
let diagnostics = validate_profile_set(&[("framing-static.yaml".to_string(), profile)]);
2209+
let diagnostic = diagnostics
2210+
.iter()
2211+
.find(|diagnostic| {
2212+
diagnostic.field == "credentials.header_name"
2213+
&& diagnostic.message.contains("HTTP framing")
2214+
})
2215+
.expect("framing header diagnostic should be reported for static credentials");
2216+
2217+
assert_eq!(
2218+
diagnostic.message,
2219+
"credential header_name may not override HTTP framing or connection headers"
2220+
);
2221+
}
2222+
2223+
#[test]
2224+
fn validate_profile_set_rejects_static_credential_invalid_header_name() {
2225+
let profile = parse_profile_yaml(
2226+
r"
2227+
id: invalid-header-static
2228+
display_name: Invalid Header Static
2229+
credentials:
2230+
- name: api_token
2231+
env_vars: [API_TOKEN]
2232+
auth_style: header
2233+
header_name: 'Invalid Header'
2234+
",
2235+
)
2236+
.expect("profile should parse");
2237+
2238+
let diagnostics =
2239+
validate_profile_set(&[("invalid-header-static.yaml".to_string(), profile)]);
2240+
let diagnostic = diagnostics
2241+
.iter()
2242+
.find(|diagnostic| {
2243+
diagnostic.field == "credentials.header_name"
2244+
&& diagnostic.message.contains("not a valid HTTP header name")
2245+
})
2246+
.expect("invalid header name diagnostic should be reported for static credentials");
2247+
2248+
assert_eq!(
2249+
diagnostic.message,
2250+
"credential header_name is not a valid HTTP header name"
2251+
);
2252+
}
2253+
2254+
#[test]
2255+
fn validate_profile_set_accepts_static_credential_valid_header_name() {
2256+
let profile = parse_profile_yaml(
2257+
r"
2258+
id: valid-header-static
2259+
display_name: Valid Header Static
2260+
credentials:
2261+
- name: api_token
2262+
env_vars: [API_TOKEN]
2263+
auth_style: header
2264+
header_name: X-Api-Key
2265+
endpoints:
2266+
- host: api.example.com
2267+
port: 443
2268+
",
2269+
)
2270+
.expect("profile should parse");
2271+
2272+
let diagnostics =
2273+
validate_profile_set(&[("valid-header-static.yaml".to_string(), profile)]);
2274+
assert!(
2275+
diagnostics.is_empty(),
2276+
"valid static credential header should produce no diagnostics, got: {diagnostics:?}"
2277+
);
2278+
}
2279+
21692280
#[test]
21702281
fn validate_profile_set_rejects_ambiguous_same_credential_audience_overrides() {
21712282
let profile = parse_profile_yaml(

crates/openshell-server/src/grpc/policy.rs

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,8 +1219,12 @@ pub(super) async fn handle_get_sandbox_config(
12191219

12201220
let settings = merge_effective_settings(&global_settings, &sandbox_settings)?;
12211221
let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source);
1222-
let provider_env_revision =
1223-
compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?;
1222+
let provider_env_revision = compute_provider_env_revision(
1223+
state.store.as_ref(),
1224+
&sandbox_provider_names,
1225+
providers_v2_enabled,
1226+
)
1227+
.await?;
12241228

12251229
Ok(Response::new(GetSandboxConfigResponse {
12261230
policy,
@@ -1237,9 +1241,11 @@ pub(super) async fn handle_get_sandbox_config(
12371241
pub(super) async fn compute_provider_env_revision(
12381242
store: &Store,
12391243
provider_names: &[String],
1244+
providers_v2_enabled: bool,
12401245
) -> Result<u64, Status> {
12411246
let mut hasher = Sha256::new();
12421247
hasher.update(b"openshell-provider-env-revision-v1");
1248+
hasher.update([u8::from(providers_v2_enabled)]);
12431249

12441250
for provider_name in provider_names {
12451251
hasher.update(provider_name.as_bytes());
@@ -1366,6 +1372,16 @@ async fn profile_provider_policy_layers(
13661372
Ok(layers)
13671373
}
13681374

1375+
pub async fn is_providers_v2_enabled(store: &Store) -> bool {
1376+
load_global_settings(store)
1377+
.await
1378+
.and_then(|s| bool_setting_enabled(&s, settings::PROVIDERS_V2_ENABLED_KEY))
1379+
.unwrap_or_else(|e| {
1380+
warn!("failed to read providers_v2_enabled setting, defaulting to false: {e}");
1381+
false
1382+
})
1383+
}
1384+
13691385
fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result<bool, Status> {
13701386
match settings.settings.get(key) {
13711387
None => Ok(false),
@@ -1407,13 +1423,20 @@ pub(super) async fn handle_get_sandbox_provider_environment(
14071423
.spec
14081424
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
14091425

1426+
let providers_v2_enabled = is_providers_v2_enabled(state.store.as_ref()).await;
1427+
14101428
let provider_names = spec.providers;
14111429
let provider_env_revision =
1412-
compute_provider_env_revision(state.store.as_ref(), &provider_names).await?;
1413-
let provider_environment =
1414-
super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names)
1430+
compute_provider_env_revision(state.store.as_ref(), &provider_names, providers_v2_enabled)
14151431
.await?;
14161432

1433+
let provider_environment = super::provider::resolve_provider_environment(
1434+
state.store.as_ref(),
1435+
&provider_names,
1436+
providers_v2_enabled,
1437+
)
1438+
.await?;
1439+
14171440
info!(
14181441
sandbox_id = %sandbox_id,
14191442
provider_count = provider_names.len(),
@@ -5001,10 +5024,13 @@ mod tests {
50015024
.await
50025025
.unwrap();
50035026

5004-
let first =
5005-
compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()])
5006-
.await
5007-
.unwrap();
5027+
let first = compute_provider_env_revision(
5028+
state.store.as_ref(),
5029+
&["work-custom-token".to_string()],
5030+
false,
5031+
)
5032+
.await
5033+
.unwrap();
50085034

50095035
tokio::time::sleep(Duration::from_millis(2)).await;
50105036
state
@@ -5015,17 +5041,43 @@ mod tests {
50155041
.await
50165042
.unwrap();
50175043

5018-
let second =
5019-
compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()])
5020-
.await
5021-
.unwrap();
5044+
let second = compute_provider_env_revision(
5045+
state.store.as_ref(),
5046+
&["work-custom-token".to_string()],
5047+
false,
5048+
)
5049+
.await
5050+
.unwrap();
50225051

50235052
assert_ne!(
50245053
first, second,
50255054
"custom provider profile updates must trigger sandbox dynamic credential refresh"
50265055
);
50275056
}
50285057

5058+
#[tokio::test]
5059+
async fn provider_env_revision_changes_when_providers_v2_enabled_toggles() {
5060+
let state = test_server_state().await;
5061+
let store = state.store.as_ref();
5062+
let provider = test_provider("work-github", "github");
5063+
store.put_message(&provider).await.unwrap();
5064+
5065+
let revision_v2_off =
5066+
compute_provider_env_revision(store, &["work-github".to_string()], false)
5067+
.await
5068+
.unwrap();
5069+
5070+
let revision_v2_on =
5071+
compute_provider_env_revision(store, &["work-github".to_string()], true)
5072+
.await
5073+
.unwrap();
5074+
5075+
assert_ne!(
5076+
revision_v2_off, revision_v2_on,
5077+
"toggling providers_v2_enabled must change provider_env_revision so running sandboxes refresh"
5078+
);
5079+
}
5080+
50295081
#[tokio::test]
50305082
async fn sandbox_config_and_provider_env_follow_attached_provider_lifecycle() {
50315083
use crate::grpc::sandbox::{

0 commit comments

Comments
 (0)