Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,52 @@ async fn provider_refresh_cli_run_functions_wire_requests() {
);
}

#[tokio::test]
async fn okta_provider_refresh_cli_supports_runtime_refresh_shape() {
let ts = run_server().await;

run::provider_create(
&ts.endpoint,
"okta-runtime",
"okta",
false,
&["OKTA_ACCESS_TOKEN=token".to_string()],
&[],
&ts.tls,
)
.await
.expect("provider create");

run::provider_refresh_config(
&ts.endpoint,
run::ProviderRefreshConfigInput {
name: "okta-runtime",
credential_key: "OKTA_ACCESS_TOKEN",
strategy: "oauth2_refresh_token",
material: &[
"client_id=client-123".to_string(),
"refresh_token=refresh-abc".to_string(),
"scope=okta.apps.read".to_string(),
],
secret_material_keys: &["refresh_token".to_string()],
credential_expires_at_ms: Some(1_767_225_600_000),
},
&ts.tls,
)
.await
.expect("provider refresh configure");

let requests = ts.state.refresh_requests.lock().await.clone();
assert!(
requests.contains(&ProviderRefreshRequestLog::Configure {
provider_name: "okta-runtime".to_string(),
credential_key: "OKTA_ACCESS_TOKEN".to_string(),
expires_at_ms: Some(1_767_225_600_000),
}),
"expected configure request for okta runtime provider"
);
}

#[tokio::test]
async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() {
let ts = run_server().await;
Expand Down Expand Up @@ -1533,6 +1579,40 @@ binaries:
assert!(profile.binaries[0].harness);
}

#[tokio::test]
async fn built_in_okta_profile_is_available_via_provider_profile_api() {
let ts = run_server().await;

let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls)
.await
.expect("grpc client should connect");
let profile = client
.get_provider_profile(openshell_core::proto::GetProviderProfileRequest {
id: "okta".to_string(),
})
.await
.expect("get provider profile")
.into_inner()
.profile
.expect("profile should exist");

assert_eq!(profile.id, "okta");
assert_eq!(profile.credentials.len(), 1);
assert_eq!(profile.credentials[0].env_vars, vec!["OKTA_ACCESS_TOKEN"]);
let refresh = profile.credentials[0]
.refresh
.as_ref()
.expect("okta profile should include refresh metadata");
assert_eq!(
refresh.strategy,
ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32
);
assert_eq!(
refresh.token_url,
"https://example.okta.com/oauth2/default/v1/token"
);
}

#[tokio::test]
async fn provider_profile_import_from_directory_parse_error_prevents_partial_import() {
let ts = run_server().await;
Expand Down
39 changes: 38 additions & 1 deletion crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[
include_str!("../../../providers/claude-code.yaml"),
include_str!("../../../providers/github.yaml"),
include_str!("../../../providers/nvidia.yaml"),
include_str!("../../../providers/okta.yaml"),
];

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -1090,7 +1091,7 @@ pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> {

#[cfg(test)]
mod tests {
use openshell_core::proto::ProviderProfileCategory;
use openshell_core::proto::{ProviderCredentialRefreshStrategy, ProviderProfileCategory};

use super::{
DiscoveryProfile, ProfileError, ProviderTypeProfile, default_profiles, get_default_profile,
Expand Down Expand Up @@ -1139,6 +1140,42 @@ mod tests {
assert_eq!(proto.binaries.len(), 4);
}

#[test]
fn okta_profile_exposes_refreshable_runtime_token_shape() {
let profile = get_default_profile("okta").expect("okta profile");
let proto = profile.to_proto();

assert_eq!(proto.id, "okta");
assert_eq!(proto.credentials.len(), 1);
let credential = &proto.credentials[0];
assert_eq!(credential.name, "access_token");
assert_eq!(credential.env_vars, vec!["OKTA_ACCESS_TOKEN"]);

let refresh = credential.refresh.as_ref().expect("okta refresh metadata");
assert_eq!(
refresh.strategy,
ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32
);
assert_eq!(
refresh.token_url,
"https://example.okta.com/oauth2/default/v1/token"
);
assert!(
refresh
.material
.iter()
.any(|entry| { entry.name == "refresh_token" && entry.required && entry.secret }),
"okta profile should require a secret refresh token material entry"
);
assert!(
refresh
.material
.iter()
.any(|entry| entry.name == "client_id" && entry.required),
"okta profile should require client_id refresh material"
);
}

#[test]
fn credential_env_vars_are_deduplicated_in_profile_order() {
let profile = get_default_profile("claude-code").expect("claude-code profile");
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::os::unix::io::RawFd;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::{Child, Command};
use tracing::{debug, warn};
use tracing::debug;

fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap<String, String>) {
for (key, value) in provider_env {
Expand Down
67 changes: 67 additions & 0 deletions crates/openshell-server/src/auth/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const ADMIN_METHODS: &[&str] = &[
"/openshell.v1.OpenShell/CreateProvider",
"/openshell.v1.OpenShell/UpdateProvider",
"/openshell.v1.OpenShell/DeleteProvider",
"/openshell.v1.OpenShell/ImportProviderProfiles",
"/openshell.v1.OpenShell/LintProviderProfiles",
"/openshell.v1.OpenShell/DeleteProviderProfile",
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
"/openshell.v1.OpenShell/RotateProviderCredential",
"/openshell.v1.OpenShell/DeleteProviderRefresh",
Expand Down Expand Up @@ -81,6 +84,14 @@ const SCOPED_METHODS: &[(&str, &str)] = &[
// provider:read
("/openshell.v1.OpenShell/GetProvider", "provider:read"),
("/openshell.v1.OpenShell/ListProviders", "provider:read"),
(
"/openshell.v1.OpenShell/ListProviderProfiles",
"provider:read",
),
(
"/openshell.v1.OpenShell/GetProviderProfile",
"provider:read",
),
(
"/openshell.v1.OpenShell/GetProviderRefreshStatus",
"provider:read",
Expand All @@ -89,6 +100,18 @@ const SCOPED_METHODS: &[(&str, &str)] = &[
("/openshell.v1.OpenShell/CreateProvider", "provider:write"),
("/openshell.v1.OpenShell/UpdateProvider", "provider:write"),
("/openshell.v1.OpenShell/DeleteProvider", "provider:write"),
(
"/openshell.v1.OpenShell/ImportProviderProfiles",
"provider:write",
),
(
"/openshell.v1.OpenShell/LintProviderProfiles",
"provider:write",
),
(
"/openshell.v1.OpenShell/DeleteProviderProfile",
"provider:write",
),
(
"/openshell.v1.OpenShell/ConfigureProviderRefresh",
"provider:write",
Expand Down Expand Up @@ -563,6 +586,50 @@ mod tests {
}
}

#[test]
fn provider_profile_methods_require_provider_scopes_and_admin_for_writes() {
let policy = scoped_policy();
let reader = identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]);
for method in [
"/openshell.v1.OpenShell/ListProviderProfiles",
"/openshell.v1.OpenShell/GetProviderProfile",
] {
assert!(policy.check(&reader, method).is_ok(), "{method}");
}

let writer_without_admin =
identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]);
let err = policy
.check(
&writer_without_admin,
"/openshell.v1.OpenShell/ImportProviderProfiles",
)
.unwrap_err();
assert_eq!(err.code(), tonic::Code::PermissionDenied);
assert!(err.message().contains("openshell-admin"));

let admin_without_scope =
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]);
let err = policy
.check(
&admin_without_scope,
"/openshell.v1.OpenShell/DeleteProviderProfile",
)
.unwrap_err();
assert_eq!(err.code(), tonic::Code::PermissionDenied);
assert!(err.message().contains("provider:write"));

let admin_writer =
identity_with_roles_and_scopes(&["openshell-admin"], &["provider:write"]);
for method in [
"/openshell.v1.OpenShell/ImportProviderProfiles",
"/openshell.v1.OpenShell/LintProviderProfiles",
"/openshell.v1.OpenShell/DeleteProviderProfile",
] {
assert!(policy.check(&admin_writer, method).is_ok(), "{method}");
}
}

#[test]
fn get_sandbox_config_requires_config_read_scope() {
let policy = scoped_policy();
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-server/src/grpc/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1617,7 +1617,7 @@ mod tests {
.iter()
.map(|profile| profile.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["claude-code", "github", "nvidia"]);
assert_eq!(ids, vec!["claude-code", "github", "nvidia", "okta"]);

let github = response
.profiles
Expand Down
2 changes: 0 additions & 2 deletions crates/openshell-server/src/provider_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,6 @@ mod tests {
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains("grant_type=client_credentials"))
.and(body_string_contains("client_id=client-id"))
.and(body_string_contains(
"scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
))
Expand Down Expand Up @@ -990,7 +989,6 @@ mod tests {
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("client_id=client-id"))
.and(body_string_contains("refresh_token=old-refresh-token"))
.and(body_string_contains(
"scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
Expand Down
5 changes: 5 additions & 0 deletions docs/get-started/tutorials/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust
Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation.
</Card>

<Card title="Okta Provider Refresh" href="/get-started/tutorials/okta-provider-refresh">

Configure a Providers v2 Okta runtime provider with gateway-managed OAuth2 refresh-token rotation.
</Card>

<Card title="Inference with Ollama" href="/get-started/tutorials/inference-ollama">

Route inference through Ollama using cloud-hosted or local models, and verify it from a sandbox.
Expand Down
Loading
Loading