Skip to content

Commit 7667f2f

Browse files
committed
fix(providers): tighten refresh authorization and collisions
1 parent 70f4361 commit 7667f2f

3 files changed

Lines changed: 250 additions & 13 deletions

File tree

crates/openshell-cli/src/run.rs

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4079,15 +4079,27 @@ pub async fn provider_create(
40794079
}
40804080

40814081
fn provider_profile_allows_refresh_bootstrap(profile: &ProviderProfile) -> bool {
4082-
profile.credentials.iter().any(|credential| {
4083-
credential.refresh.as_ref().is_some_and(|refresh| {
4084-
matches!(
4085-
ProviderCredentialRefreshStrategy::try_from(refresh.strategy),
4086-
Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials
4087-
| ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
4088-
)
4082+
let required_credentials = profile
4083+
.credentials
4084+
.iter()
4085+
.filter(|credential| credential.required)
4086+
.collect::<Vec<_>>();
4087+
!required_credentials.is_empty()
4088+
&& required_credentials.iter().all(|credential| {
4089+
credential
4090+
.refresh
4091+
.as_ref()
4092+
.is_some_and(|refresh| is_gateway_mintable_refresh_strategy(refresh.strategy))
40894093
})
4090-
})
4094+
}
4095+
4096+
fn is_gateway_mintable_refresh_strategy(strategy: i32) -> bool {
4097+
matches!(
4098+
ProviderCredentialRefreshStrategy::try_from(strategy),
4099+
Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken
4100+
| ProviderCredentialRefreshStrategy::Oauth2ClientCredentials
4101+
| ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
4102+
)
40914103
}
40924104

40934105
pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
@@ -6593,9 +6605,9 @@ mod tests {
65936605
git_sync_files, http_health_check, image_requests_gpu, import_local_package_mtls_bundle,
65946606
inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value,
65956607
parse_credential_pairs, plaintext_gateway_is_remote, progress_step_from_metadata,
6596-
provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
6597-
refresh_status_row, resolve_from, sandbox_should_persist, service_expose_status_error,
6598-
service_url_for_gateway,
6608+
provider_profile_allows_refresh_bootstrap, provisioning_timeout_message,
6609+
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
6610+
sandbox_should_persist, service_expose_status_error, service_url_for_gateway,
65996611
};
66006612
use crate::TEST_ENV_LOCK;
66016613
use hyper::StatusCode;
@@ -6614,7 +6626,8 @@ mod tests {
66146626
PROGRESS_STEP_STARTING_SANDBOX,
66156627
};
66166628
use openshell_core::proto::{
6617-
Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy,
6629+
Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus,
6630+
ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCredential,
66186631
SandboxCondition, SandboxStatus, datamodel::v1::ObjectMeta,
66196632
};
66206633

@@ -6799,6 +6812,65 @@ mod tests {
67996812
assert!(row.contains("..."));
68006813
}
68016814

6815+
#[test]
6816+
fn refresh_bootstrap_requires_all_required_credentials_to_be_gateway_mintable() {
6817+
let refresh_token_profile = ProviderProfile {
6818+
credentials: vec![ProviderProfileCredential {
6819+
name: "MS_GRAPH_ACCESS_TOKEN".to_string(),
6820+
required: true,
6821+
refresh: Some(ProviderCredentialRefresh {
6822+
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
6823+
..Default::default()
6824+
}),
6825+
..Default::default()
6826+
}],
6827+
..Default::default()
6828+
};
6829+
assert!(provider_profile_allows_refresh_bootstrap(
6830+
&refresh_token_profile
6831+
));
6832+
6833+
let mixed_static_profile = ProviderProfile {
6834+
credentials: vec![
6835+
ProviderProfileCredential {
6836+
name: "ACCESS_TOKEN".to_string(),
6837+
required: true,
6838+
refresh: Some(ProviderCredentialRefresh {
6839+
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
6840+
..Default::default()
6841+
}),
6842+
..Default::default()
6843+
},
6844+
ProviderProfileCredential {
6845+
name: "STATIC_API_KEY".to_string(),
6846+
required: true,
6847+
refresh: None,
6848+
..Default::default()
6849+
},
6850+
],
6851+
..Default::default()
6852+
};
6853+
assert!(!provider_profile_allows_refresh_bootstrap(
6854+
&mixed_static_profile
6855+
));
6856+
6857+
let optional_refresh_profile = ProviderProfile {
6858+
credentials: vec![ProviderProfileCredential {
6859+
name: "OPTIONAL_TOKEN".to_string(),
6860+
required: false,
6861+
refresh: Some(ProviderCredentialRefresh {
6862+
strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32,
6863+
..Default::default()
6864+
}),
6865+
..Default::default()
6866+
}],
6867+
..Default::default()
6868+
};
6869+
assert!(!provider_profile_allows_refresh_bootstrap(
6870+
&optional_refresh_profile
6871+
));
6872+
}
6873+
68026874
#[cfg(feature = "dev-settings")]
68036875
#[test]
68046876
fn parse_cli_setting_value_parses_bool_aliases() {

crates/openshell-server/src/auth/authz.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const ADMIN_METHODS: &[&str] = &[
2222
"/openshell.v1.OpenShell/CreateProvider",
2323
"/openshell.v1.OpenShell/UpdateProvider",
2424
"/openshell.v1.OpenShell/DeleteProvider",
25+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
26+
"/openshell.v1.OpenShell/RotateProviderCredential",
27+
"/openshell.v1.OpenShell/DeleteProviderRefresh",
2528
// Global config and policy
2629
"/openshell.v1.OpenShell/UpdateConfig",
2730
// Draft policy approvals
@@ -77,10 +80,26 @@ const SCOPED_METHODS: &[(&str, &str)] = &[
7780
// provider:read
7881
("/openshell.v1.OpenShell/GetProvider", "provider:read"),
7982
("/openshell.v1.OpenShell/ListProviders", "provider:read"),
83+
(
84+
"/openshell.v1.OpenShell/GetProviderRefreshStatus",
85+
"provider:read",
86+
),
8087
// provider:write
8188
("/openshell.v1.OpenShell/CreateProvider", "provider:write"),
8289
("/openshell.v1.OpenShell/UpdateProvider", "provider:write"),
8390
("/openshell.v1.OpenShell/DeleteProvider", "provider:write"),
91+
(
92+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
93+
"provider:write",
94+
),
95+
(
96+
"/openshell.v1.OpenShell/RotateProviderCredential",
97+
"provider:write",
98+
),
99+
(
100+
"/openshell.v1.OpenShell/DeleteProviderRefresh",
101+
"provider:write",
102+
),
84103
// config:read
85104
("/openshell.v1.OpenShell/GetGatewayConfig", "config:read"),
86105
("/openshell.v1.OpenShell/GetSandboxConfig", "config:read"),
@@ -500,6 +519,49 @@ mod tests {
500519
assert!(err.message().contains("sandbox:write"));
501520
}
502521

522+
#[test]
523+
fn provider_refresh_methods_require_provider_scopes_and_admin_for_writes() {
524+
let policy = scoped_policy();
525+
let reader = identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]);
526+
assert!(
527+
policy
528+
.check(&reader, "/openshell.v1.OpenShell/GetProviderRefreshStatus")
529+
.is_ok()
530+
);
531+
532+
let writer_without_admin =
533+
identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]);
534+
let err = policy
535+
.check(
536+
&writer_without_admin,
537+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
538+
)
539+
.unwrap_err();
540+
assert_eq!(err.code(), tonic::Code::PermissionDenied);
541+
assert!(err.message().contains("openshell-admin"));
542+
543+
let admin_without_scope =
544+
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]);
545+
let err = policy
546+
.check(
547+
&admin_without_scope,
548+
"/openshell.v1.OpenShell/RotateProviderCredential",
549+
)
550+
.unwrap_err();
551+
assert_eq!(err.code(), tonic::Code::PermissionDenied);
552+
assert!(err.message().contains("provider:write"));
553+
554+
let admin_writer =
555+
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:write"]);
556+
for method in [
557+
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
558+
"/openshell.v1.OpenShell/RotateProviderCredential",
559+
"/openshell.v1.OpenShell/DeleteProviderRefresh",
560+
] {
561+
assert!(policy.check(&admin_writer, method).is_ok(), "{method}");
562+
}
563+
}
564+
503565
#[test]
504566
fn get_sandbox_config_requires_config_read_scope() {
505567
let policy = scoped_policy();

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

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ async fn validate_provider_environment_keys_unique_at(
482482
})?,
483483
};
484484
let provider_name = provider.object_name().to_string();
485-
for key in active_provider_credential_keys(&provider, now_ms) {
485+
for key in active_provider_environment_keys(store, &provider, now_ms).await? {
486486
if let Some(first_provider) = seen.get(&key) {
487487
if first_provider != &provider_name {
488488
return Err(Status::failed_precondition(format!(
@@ -497,6 +497,26 @@ async fn validate_provider_environment_keys_unique_at(
497497
Ok(())
498498
}
499499

500+
async fn active_provider_environment_keys(
501+
store: &Store,
502+
provider: &Provider,
503+
now_ms: i64,
504+
) -> Result<Vec<String>, Status> {
505+
let mut keys = active_provider_credential_keys(provider, now_ms);
506+
if !provider.object_id().is_empty() {
507+
keys.extend(
508+
crate::provider_refresh::list_refresh_states_for_provider(store, provider.object_id())
509+
.await?
510+
.into_iter()
511+
.map(|state| state.credential_key)
512+
.filter(|key| is_valid_env_key(key)),
513+
);
514+
}
515+
keys.sort();
516+
keys.dedup();
517+
Ok(keys)
518+
}
519+
500520
fn active_provider_credential_keys(provider: &Provider, now_ms: i64) -> Vec<String> {
501521
provider
502522
.credentials
@@ -2289,6 +2309,89 @@ mod tests {
22892309
assert!(states.is_empty());
22902310
}
22912311

2312+
#[tokio::test]
2313+
async fn configure_provider_refresh_treats_existing_refresh_state_keys_as_reserved() {
2314+
let state = test_server_state().await;
2315+
for name in ["first-graph", "second-graph"] {
2316+
create_provider_record(
2317+
state.store.as_ref(),
2318+
Provider {
2319+
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
2320+
id: String::new(),
2321+
name: name.to_string(),
2322+
created_at_ms: 0,
2323+
labels: HashMap::new(),
2324+
}),
2325+
r#type: "outlook".to_string(),
2326+
credentials: HashMap::new(),
2327+
config: HashMap::new(),
2328+
credential_expires_at_ms: HashMap::new(),
2329+
},
2330+
)
2331+
.await
2332+
.unwrap();
2333+
}
2334+
state
2335+
.store
2336+
.put_message(&Sandbox {
2337+
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
2338+
id: "sandbox-refresh-collision".to_string(),
2339+
name: "refresh-collision".to_string(),
2340+
created_at_ms: 0,
2341+
labels: HashMap::new(),
2342+
}),
2343+
spec: Some(SandboxSpec {
2344+
providers: vec!["first-graph".to_string(), "second-graph".to_string()],
2345+
..SandboxSpec::default()
2346+
}),
2347+
..Default::default()
2348+
})
2349+
.await
2350+
.unwrap();
2351+
2352+
handle_configure_provider_refresh(
2353+
&state,
2354+
Request::new(ConfigureProviderRefreshRequest {
2355+
provider: "first-graph".to_string(),
2356+
credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(),
2357+
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
2358+
material: HashMap::from([
2359+
("tenant_id".to_string(), "tenant".to_string()),
2360+
("client_id".to_string(), "client-id".to_string()),
2361+
("client_secret".to_string(), "client-secret".to_string()),
2362+
]),
2363+
secret_material_keys: vec!["client_secret".to_string()],
2364+
expires_at_ms: None,
2365+
}),
2366+
)
2367+
.await
2368+
.unwrap();
2369+
2370+
let err = handle_configure_provider_refresh(
2371+
&state,
2372+
Request::new(ConfigureProviderRefreshRequest {
2373+
provider: "second-graph".to_string(),
2374+
credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(),
2375+
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
2376+
material: HashMap::from([
2377+
("tenant_id".to_string(), "tenant".to_string()),
2378+
("client_id".to_string(), "client-id".to_string()),
2379+
("client_secret".to_string(), "client-secret".to_string()),
2380+
]),
2381+
secret_material_keys: vec!["client_secret".to_string()],
2382+
expires_at_ms: None,
2383+
}),
2384+
)
2385+
.await
2386+
.unwrap_err();
2387+
2388+
assert_eq!(err.code(), Code::FailedPrecondition);
2389+
assert!(err.message().contains("collision"));
2390+
assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN"));
2391+
assert!(err.message().contains("first-graph"));
2392+
assert!(err.message().contains("second-graph"));
2393+
}
2394+
22922395
#[tokio::test]
22932396
async fn configure_provider_refresh_rejects_profile_endpoint_override_and_missing_material() {
22942397
let state = test_server_state().await;

0 commit comments

Comments
 (0)