feat(service): add bearer authorization passthrough (#3796)

* feat(service): add bearer authorization passthrough

Signed-off-by: Derek Carr <decarr@redhat.com>

* docs(sdk): add service authorization migration guide

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(server): remove stale version import

Signed-off-by: Derek Carr <decarr@redhat.com>

* docs(upgrade): remove service authorization SDK guide

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(e2e): relabel provider readiness TLS mount

Signed-off-by: Derek Carr <decarr@redhat.com>

* test(e2e): stabilize exposed service routing

Signed-off-by: Derek Carr <decarr@redhat.com>

* test(e2e): support HTTPS service routing

Signed-off-by: Derek Carr <decarr@redhat.com>

---------

Signed-off-by: Derek Carr <decarr@redhat.com>
This commit is contained in:
Derek Carr
2026-09-30 20:22:46 +00:00
committed by GitHub
parent 374c035962
commit 912a077bd6
39 changed files with 2684 additions and 907 deletions
+29
View File
@@ -178,6 +178,35 @@ Rust-based e2e tests that exercise the `openshell` CLI binary as a subprocess.
They live in the `openshell-e2e` crate and use a shared harness for sandbox
lifecycle management, output parsing, and cleanup.
Exposed service URLs use virtual hostnames for gateway routing. Host-side tests
must connect the TCP socket directly to a reachable gateway listener address,
normally loopback, and send the service URL authority in the HTTP `Host`
header. Do not resolve `*.openshell.localhost`; resolver support for arbitrary
`.localhost` subdomains varies across local and CI environments.
Treat the advertised service URL scheme as authoritative. For HTTPS, use the
virtual service hostname for TLS SNI and the configured gateway trust roots.
When the listener requires mTLS, present the active gateway client identity;
the local e2e wrappers register these materials under
`$XDG_CONFIG_HOME/openshell/gateways/$OPENSHELL_GATEWAY/mtls/`. Do not downgrade
an HTTPS service URL to plaintext when dialing loopback. Parse the URL and load
TLS material before entering a readiness loop so permanent configuration
errors fail immediately. Retry only transient connection failures and
documented readiness responses, and include the last observation in timeout
diagnostics.
Verify exposed-service tests in both the default local mode and the
CI-equivalent HTTPS mode:
```shell
mise run e2e:rust
OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false mise run e2e:rust
```
When more than one test needs this behavior, put the transport in the shared
Rust e2e harness and require callers to use it instead of duplicating DNS,
HTTP `Host`, TLS SNI, and mTLS handling.
Suites:
- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
+110 -2
View File
@@ -19,7 +19,7 @@ use openshell_bootstrap::{
use openshell_cli::completers;
use openshell_cli::run;
use openshell_cli::tls::TlsOptions;
use openshell_core::proto::GpuResourceRequirements;
use openshell_core::proto::{GpuResourceRequirements, ServiceAuthorizationMode};
/// Resolved gateway context: name + gateway endpoint.
struct GatewayContext {
@@ -770,6 +770,22 @@ enum OutputFormat {
Json,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum CliServiceAuthorizationMode {
#[default]
Strip,
BearerPassthrough,
}
impl From<CliServiceAuthorizationMode> for ServiceAuthorizationMode {
fn from(value: CliServiceAuthorizationMode) -> Self {
match value {
CliServiceAuthorizationMode::Strip => Self::Strip,
CliServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
}
}
}
#[derive(Clone, Debug, ValueEnum)]
enum CliProviderRefreshStrategy {
Oauth2RefreshToken,
@@ -1520,6 +1536,10 @@ enum SandboxCommands {
)]
expose: Option<u16>,
/// Handling for an incoming application Authorization header.
#[arg(long, value_enum, default_value_t, requires = "expose")]
expose_authorization_mode: CliServiceAuthorizationMode,
/// Allocate a pseudo-terminal for the remote command.
/// Defaults to auto-detection (on when stdin and stdout are terminals).
/// Use --tty to force a PTY even when auto-detection fails, or
@@ -2369,6 +2389,10 @@ enum ServiceCommands {
/// Service name.
service: Option<String>,
/// Handling for an incoming application Authorization header.
#[arg(long, value_enum, default_value_t)]
authorization_mode: CliServiceAuthorizationMode,
},
/// List exposed sandbox service endpoints.
@@ -2914,6 +2938,7 @@ async fn run_async() -> Result<()> {
sandbox,
service,
target_port,
authorization_mode,
} => {
let service = service.unwrap_or_default();
run::service_expose(
@@ -2921,6 +2946,7 @@ async fn run_async() -> Result<()> {
&sandbox,
&service,
target_port,
authorization_mode.into(),
&cli.workspace,
&tls,
)
@@ -3324,6 +3350,7 @@ async fn run_async() -> Result<()> {
policy,
forward,
expose,
expose_authorization_mode,
tty,
no_tty,
detach,
@@ -3421,6 +3448,7 @@ async fn run_async() -> Result<()> {
policy: policy.as_deref(),
forward,
expose,
expose_authorization_mode: expose_authorization_mode.into(),
command: &command,
tty_override,
auto_providers_override,
@@ -6716,9 +6744,19 @@ mod tests {
match cli.command {
Some(Commands::Sandbox {
command: Some(SandboxCommands::Create { expose, detach, .. }),
command:
Some(SandboxCommands::Create {
expose,
expose_authorization_mode,
detach,
..
}),
}) => {
assert_eq!(expose, Some(4500));
assert_eq!(
expose_authorization_mode,
CliServiceAuthorizationMode::Strip
);
assert!(detach);
}
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
@@ -6746,6 +6784,44 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn sandbox_create_parses_bearer_passthrough_and_requires_expose() {
let cli = Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--expose",
"4500",
"--expose-authorization-mode",
"bearer-passthrough",
])
.expect("create-time authorization mode should parse with --expose");
match cli.command {
Some(Commands::Sandbox {
command:
Some(SandboxCommands::Create {
expose_authorization_mode,
..
}),
}) => assert_eq!(
expose_authorization_mode,
CliServiceAuthorizationMode::BearerPassthrough
),
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
}
assert!(
Cli::try_parse_from([
"openshell",
"sandbox",
"create",
"--expose-authorization-mode",
"bearer-passthrough",
])
.is_err()
);
}
#[test]
fn service_expose_accepts_positional_target_port_and_service() {
let cli = Cli::try_parse_from([
@@ -6765,11 +6841,13 @@ mod tests {
sandbox,
target_port,
service,
authorization_mode,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service.as_deref(), Some("api"));
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
}
other => panic!("expected service expose command, got: {other:?}"),
}
@@ -6787,16 +6865,45 @@ mod tests {
sandbox,
target_port,
service,
authorization_mode,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service, None);
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
}
other => panic!("expected service expose command, got: {other:?}"),
}
}
#[test]
fn service_expose_parses_bearer_passthrough() {
let cli = Cli::try_parse_from([
"openshell",
"service",
"expose",
"my-sandbox",
"4500",
"--authorization-mode",
"bearer-passthrough",
])
.expect("service authorization mode should parse");
match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
authorization_mode, ..
}),
}) => assert_eq!(
authorization_mode,
CliServiceAuthorizationMode::BearerPassthrough
),
other => panic!("expected service expose command, got: {other:?}"),
}
}
#[test]
fn service_alias_parses_service_commands() {
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
@@ -6809,6 +6916,7 @@ mod tests {
sandbox,
target_port,
service,
..
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
+46 -16
View File
@@ -58,9 +58,9 @@ use openshell_core::proto::{
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SettingScope, StartSandboxRequest,
StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpointResponse, SettingScope,
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
};
use openshell_core::settings;
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
@@ -443,6 +443,7 @@ pub struct SandboxCreateConfig<'a> {
pub policy: Option<&'a str>,
pub forward: Option<ForwardSpec>,
pub expose: Option<u16>,
pub expose_authorization_mode: ServiceAuthorizationMode,
pub command: &'a [String],
pub tty_override: Option<bool>,
pub auto_providers_override: Option<bool>,
@@ -472,6 +473,7 @@ impl Default for SandboxCreateConfig<'_> {
policy: None,
forward: None,
expose: None,
expose_authorization_mode: ServiceAuthorizationMode::Strip,
command: &[],
tty_override: None,
auto_providers_override: None,
@@ -509,6 +511,7 @@ pub async fn sandbox_create(
policy,
forward,
expose,
expose_authorization_mode,
command,
tty_override,
auto_providers_override,
@@ -693,6 +696,7 @@ pub async fn sandbox_create(
.map(|target_port| SandboxServiceExposure {
service: String::new(),
target_port: u32::from(target_port),
authorization_mode: expose_authorization_mode as i32,
})
.into_iter()
.collect(),
@@ -3823,11 +3827,20 @@ pub async fn service_expose(
sandbox: &str,
service: &str,
target_port: u16,
authorization_mode: ServiceAuthorizationMode,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let response =
expose_service_endpoint(server, sandbox, service, target_port, workspace, tls).await?;
let response = expose_service_endpoint(
server,
sandbox,
service,
target_port,
authorization_mode,
workspace,
tls,
)
.await?;
if service.is_empty() {
println!(
@@ -3857,6 +3870,7 @@ async fn expose_service_endpoint(
sandbox: &str,
service: &str,
target_port: u16,
authorization_mode: ServiceAuthorizationMode,
workspace: &str,
tls: &TlsOptions,
) -> Result<ServiceEndpointResponse> {
@@ -3868,6 +3882,7 @@ async fn expose_service_endpoint(
name: service.to_string(),
target_port: u32::from(target_port),
domain: true,
authorization_mode: authorization_mode as i32,
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
@@ -4040,6 +4055,7 @@ fn print_service_endpoint_table(
.map_or("", |m| m.workspace.as_str());
let service = service_display_name(&endpoint.name).to_string();
let target = format!("127.0.0.1:{}", endpoint.target_port);
let authorization = service_authorization_mode_name(endpoint.authorization_mode);
let url = if response.url.is_empty() {
String::new()
} else {
@@ -4050,6 +4066,7 @@ fn print_service_endpoint_table(
endpoint.sandbox.clone(),
service,
target,
authorization,
url,
))
})
@@ -4061,7 +4078,7 @@ fn print_service_endpoint_table(
let ws_width = if all_workspaces {
rows.iter()
.map(|(ws, _, _, _, _)| ws.len())
.map(|(ws, _, _, _, _, _)| ws.len())
.max()
.unwrap_or(9)
.max(9)
@@ -4070,50 +4087,52 @@ fn print_service_endpoint_table(
};
let sandbox_width = rows
.iter()
.map(|(_, sandbox, _, _, _)| sandbox.len())
.map(|(_, sandbox, _, _, _, _)| sandbox.len())
.max()
.unwrap_or(7)
.max(7);
let service_width = rows
.iter()
.map(|(_, _, service, _, _)| service.len())
.map(|(_, _, service, _, _, _)| service.len())
.max()
.unwrap_or(7)
.max(7);
let target_width = rows
.iter()
.map(|(_, _, _, target, _)| target.len())
.map(|(_, _, _, target, _, _)| target.len())
.max()
.unwrap_or(6)
.max(6);
if all_workspaces {
println!(
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
"WORKSPACE".bold(),
"SANDBOX".bold(),
"SERVICE".bold(),
"TARGET".bold(),
"AUTHORIZATION".bold(),
"URL".bold(),
);
} else {
println!(
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
"SANDBOX".bold(),
"SERVICE".bold(),
"TARGET".bold(),
"AUTHORIZATION".bold(),
"URL".bold(),
);
}
for (workspace, sandbox, service, target, url) in rows {
for (workspace, sandbox, service, target, authorization, url) in rows {
if all_workspaces {
println!(
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
);
} else {
println!(
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
);
}
}
@@ -4139,6 +4158,7 @@ fn service_endpoint_to_json(
"sandbox": endpoint.sandbox,
"service": endpoint.name,
"target_port": endpoint.target_port,
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
"url": url,
}))
}
@@ -4147,6 +4167,13 @@ fn service_display_name(service: &str) -> &str {
if service.is_empty() { "-" } else { service }
}
fn service_authorization_mode_name(mode: i32) -> &'static str {
match ServiceAuthorizationMode::try_from(mode).unwrap_or(ServiceAuthorizationMode::Strip) {
ServiceAuthorizationMode::BearerPassthrough => "bearer_passthrough",
ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip => "strip",
}
}
/// Read gcloud Application Default Credentials from disk.
///
/// Returns `(client_id, client_secret, refresh_token)`.
@@ -6521,8 +6548,9 @@ mod tests {
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceEndpoint,
ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole,
datamodel::v1::ObjectMeta,
};
#[test]
@@ -6639,6 +6667,7 @@ mod tests {
sandbox: "api".to_string(),
name: String::new(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
..Default::default()
}),
url: "https://api.openshell.localhost:3000/".to_string(),
@@ -6653,6 +6682,7 @@ mod tests {
"sandbox": "api",
"service": "",
"target_port": 8080,
"authorization_mode": "bearer_passthrough",
"url": "https://api.openshell.localhost:17670/",
})
);
@@ -2784,6 +2784,8 @@ async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() {
name: Some("sandbox"),
keep: false,
expose: Some(4500),
expose_authorization_mode:
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough,
detach: true,
..test_config()
},
@@ -2799,9 +2801,38 @@ async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() {
assert_eq!(create_requests[0].service_exposures.len(), 1);
assert_eq!(create_requests[0].service_exposures[0].service, "");
assert_eq!(create_requests[0].service_exposures[0].target_port, 4500);
assert_eq!(
create_requests[0].service_exposures[0].authorization_mode(),
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough
);
assert!(expose_service_requests(&server).await.is_empty());
}
#[tokio::test]
async fn service_expose_forwards_bearer_passthrough_mode() {
let server = run_server().await;
let tls = test_tls(&server);
run::service_expose(
&server.endpoint,
"sandbox",
"codex",
4500,
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough,
"default",
&tls,
)
.await
.expect("service expose should succeed");
let requests = expose_service_requests(&server).await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization_mode(),
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough
);
}
#[tokio::test]
async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() {
let server = run_server().await;
+4 -2
View File
@@ -56,8 +56,10 @@ portable workload shape and driver config. Failures map to a typed `SdkError`
with a discriminable kind.
Set `SandboxSpec::service_exposures` to register named or unnamed loopback HTTP
services during creation. Each `ServiceExposure` contains a service name and a
target port; an empty name selects the unnamed endpoint. The returned
services during creation. Each `ServiceExposure` contains a service name, a
target port, and an authorization mode; an empty name selects the unnamed
endpoint. Authorization is stripped by default. Select `BearerPassthrough` only
when the sandbox application validates its own bearer credential. The returned
`SandboxRef::service_urls` map contains each routed URL under the same name.
Curated calls without a workspace argument explicitly select the `default`
+6
View File
@@ -1359,6 +1359,9 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
.map(|exposure| proto::SandboxServiceExposure {
service: exposure.service,
target_port: u32::from(exposure.target_port),
authorization_mode: proto::ServiceAuthorizationMode::from(
exposure.authorization_mode,
) as i32,
})
.collect(),
}
@@ -1397,6 +1400,9 @@ fn create_sandbox_from_template_request(
.map(|exposure| proto::SandboxServiceExposure {
service: exposure.service,
target_port: u32::from(exposure.target_port),
authorization_mode: proto::ServiceAuthorizationMode::from(
exposure.authorization_mode,
) as i32,
})
.collect(),
}
+2 -2
View File
@@ -54,6 +54,6 @@ pub use types::{
LogLine, PlatformEvent, SandboxPhase, SandboxRef, SandboxResources, SandboxRestartPolicy,
SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec,
SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceExposure, ServiceStatus,
WatchEvent, WatchOptions, WorkspaceRef,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
ServiceExposure, ServiceStatus, WatchEvent, WatchOptions, WorkspaceRef,
};
+21
View File
@@ -296,6 +296,27 @@ pub struct ServiceExposure {
pub service: String,
/// Loopback TCP port inside the sandbox.
pub target_port: u16,
/// Whether the gateway strips or forwards an application bearer credential.
pub authorization_mode: ServiceAuthorizationMode,
}
/// Handling for an incoming application `Authorization` header.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ServiceAuthorizationMode {
/// Remove the header before proxying to the sandbox service.
#[default]
Strip,
/// Forward one syntactically valid bearer credential unchanged.
BearerPassthrough,
}
impl From<ServiceAuthorizationMode> for proto::ServiceAuthorizationMode {
fn from(value: ServiceAuthorizationMode) -> Self {
match value {
ServiceAuthorizationMode::Strip => Self::Strip,
ServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
}
}
}
/// Caller intent for creating a sandbox from a named workload template.
+7 -2
View File
@@ -13,8 +13,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_sdk::{
AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError,
RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec,
SandboxTemplateListOptions, ServiceExposure, ServiceStatus as SdkServiceStatus, WatchEvent,
WatchOptions,
SandboxTemplateListOptions, ServiceAuthorizationMode, ServiceExposure,
ServiceStatus as SdkServiceStatus, WatchEvent, WatchOptions,
};
use std::collections::HashMap;
use std::sync::Arc;
@@ -1139,6 +1139,7 @@ async fn create_sandbox_passes_spec_through() {
service_exposures: vec![ServiceExposure {
service: "web".to_string(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::BearerPassthrough,
}],
..Default::default()
};
@@ -1158,6 +1159,10 @@ async fn create_sandbox_passes_spec_through() {
assert_eq!(observed.service_exposures.len(), 1);
assert_eq!(observed.service_exposures[0].service, "web");
assert_eq!(observed.service_exposures[0].target_port, 8080);
assert_eq!(
observed.service_exposures[0].authorization_mode(),
proto::ServiceAuthorizationMode::BearerPassthrough
);
let observed_spec = observed.spec.unwrap();
assert!(
observed_spec
@@ -25,6 +25,8 @@ use hyper_util::rt::TokioIo;
use openshell_core::extension_protocol::{
ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate,
};
#[cfg(test)]
use openshell_core::proto::ServiceAuthorizationMode;
use openshell_core::proto::compute::v1::{
AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest,
DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements,
@@ -9509,6 +9511,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}
}
@@ -7,7 +7,8 @@ use openshell_core::proto::datamodel::v1::ObjectMeta;
use openshell_core::proto::open_shell_server::OpenShell;
use openshell_core::proto::{
CreateSandboxRequest, SandboxServiceExposure, SandboxSpec, SandboxWorkloadConfig,
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, WorkspaceRole,
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
WorkspaceMember, WorkspaceRole,
};
use openshell_core::rpc_error::StatusExt;
use std::collections::HashMap;
@@ -113,6 +114,7 @@ async fn create_sandbox_replay_preserves_service_urls() {
service_exposures: vec![SandboxServiceExposure {
service: "web".into(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}],
request_id: uuid::Uuid::new_v4().to_string(),
..Default::default()
@@ -124,13 +126,26 @@ async fn create_sandbox_replay_preserves_service_urls() {
.unwrap()
.into_inner();
let replay = service
.create_sandbox(authed_request(request))
.create_sandbox(authed_request(request.clone()))
.await
.unwrap();
assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true");
assert_eq!(replay.get_ref().service_urls, original.service_urls);
assert_eq!(replay.into_inner(), original);
let mut changed_authorization = request;
changed_authorization.service_exposures[0].authorization_mode =
ServiceAuthorizationMode::BearerPassthrough as i32;
assert_eq!(
reason(
&service
.create_sandbox(authed_request(changed_authorization))
.await
.unwrap_err()
),
"REQUEST_ID_PAYLOAD_MISMATCH"
);
}
async fn exercise_backend(url: &str) {
+56 -2
View File
@@ -680,6 +680,11 @@ async fn handle_create_sandbox_inner(
&sandbox,
&exposure.service,
exposure.target_port,
super::service::validate_service_exposure_request(
&exposure.service,
exposure.target_port,
exposure.authorization_mode,
)?,
)
.await
{
@@ -741,7 +746,11 @@ fn validate_create_sandbox_request_pre_io(
}
let mut service_names = HashSet::with_capacity(request.service_exposures.len());
for exposure in &request.service_exposures {
super::service::validate_service_exposure_request(&exposure.service, exposure.target_port)?;
super::service::validate_service_exposure_request(
&exposure.service,
exposure.target_port,
exposure.authorization_mode,
)?;
if !service_names.insert(exposure.service.as_str()) {
return Err(Status::invalid_argument(format!(
"duplicate service exposure name: '{}'",
@@ -3924,7 +3933,9 @@ mod tests {
test_server_state_with_driver,
};
use openshell_core::proto::datamodel::v1::ObjectMeta;
use openshell_core::proto::{GpuResourceRequirements, SandboxServiceExposure, ServiceEndpoint};
use openshell_core::proto::{
GpuResourceRequirements, SandboxServiceExposure, ServiceAuthorizationMode, ServiceEndpoint,
};
// ---- shell_escape ----
@@ -6720,10 +6731,12 @@ mod tests {
SandboxServiceExposure {
service: String::new(),
target_port: 4500,
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
},
SandboxServiceExposure {
service: "metrics".to_string(),
target_port: 9090,
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
},
],
..Default::default()
@@ -6756,9 +6769,46 @@ mod tests {
assert_eq!(endpoint.name, service);
assert_eq!(endpoint.target_port, target_port);
assert!(endpoint.domain);
let expected_mode = if service.is_empty() {
ServiceAuthorizationMode::Strip
} else {
ServiceAuthorizationMode::BearerPassthrough
};
assert_eq!(endpoint.authorization_mode(), expected_mode);
}
}
#[tokio::test]
async fn create_sandbox_rejects_unknown_service_authorization_mode_before_persisting() {
let state = test_server_state().await;
let error = handle_create_sandbox(
&state,
authed_request(CreateSandboxRequest {
name: "invalid-service-authorization".to_string(),
spec: Some(SandboxSpec::default()),
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
service_exposures: vec![SandboxServiceExposure {
service: String::new(),
target_port: 4500,
authorization_mode: 99,
}],
..Default::default()
}),
)
.await
.expect_err("unknown service authorization mode should be rejected");
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert!(
state
.store
.get_message_by_name::<Sandbox>("default", "invalid-service-authorization")
.await
.expect("sandbox lookup should succeed")
.is_none()
);
}
#[tokio::test]
async fn create_sandbox_begins_rollback_when_service_exposure_fails() {
let state = test_server_state().await;
@@ -6788,10 +6838,12 @@ mod tests {
SandboxServiceExposure {
service: "web".to_string(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
},
SandboxServiceExposure {
service: "metrics".to_string(),
target_port: 9090,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
},
],
..Default::default()
@@ -6875,10 +6927,12 @@ mod tests {
SandboxServiceExposure {
service: "web".to_string(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
},
SandboxServiceExposure {
service: "web".to_string(),
target_port: 8081,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
},
],
..Default::default()
+162 -6
View File
@@ -7,7 +7,8 @@ use std::sync::Arc;
use openshell_core::proto::datamodel::v1::ObjectMeta;
use openshell_core::proto::{
DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest,
ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse,
ListServicesRequest, ListServicesResponse, Sandbox, ServiceAuthorizationMode, ServiceEndpoint,
ServiceEndpointResponse,
};
use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace};
use prost::Message as _;
@@ -49,19 +50,37 @@ pub(super) async fn handle_expose_service(
super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace())
.await?
.ensure_active()?;
validate_service_exposure_request(&req.name, req.target_port)?;
expose_service_endpoint(state, &workspace, &sandbox, &req.name, req.target_port).await
let authorization_mode =
validate_service_exposure_request(&req.name, req.target_port, req.authorization_mode)?;
expose_service_endpoint(
state,
&workspace,
&sandbox,
&req.name,
req.target_port,
authorization_mode,
)
.await
}
pub(super) fn validate_service_exposure_request(
service: &str,
target_port: u32,
) -> Result<(), Status> {
authorization_mode: i32,
) -> Result<ServiceAuthorizationMode, Status> {
validate_optional_endpoint_name("service", service, MAX_SERVICE_NAME_LEN)?;
if target_port == 0 || target_port > u32::from(u16::MAX) {
return Err(Status::invalid_argument("target_port must be in 1..=65535"));
}
Ok(())
match ServiceAuthorizationMode::try_from(authorization_mode) {
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) => {
Ok(ServiceAuthorizationMode::Strip)
}
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
Ok(ServiceAuthorizationMode::BearerPassthrough)
}
Err(_) => Err(Status::invalid_argument("authorization_mode is invalid")),
}
}
pub(super) async fn expose_service_endpoint(
@@ -70,6 +89,7 @@ pub(super) async fn expose_service_endpoint(
sandbox: &Sandbox,
service: &str,
target_port: u32,
authorization_mode: ServiceAuthorizationMode,
) -> Result<Response<ServiceEndpointResponse>, Status> {
let sandbox_name = sandbox.object_name();
@@ -132,6 +152,7 @@ pub(super) async fn expose_service_endpoint(
name: service.to_string(),
target_port,
domain: true,
authorization_mode: authorization_mode as i32,
};
// Single-attempt CAS write: fails with ABORTED on concurrent modification
@@ -344,8 +365,16 @@ async fn get_service_endpoint(
fn service_endpoint_response(
state: &Arc<ServerState>,
endpoint: ServiceEndpoint,
mut endpoint: ServiceEndpoint,
) -> ServiceEndpointResponse {
endpoint.authorization_mode =
match ServiceAuthorizationMode::try_from(endpoint.authorization_mode) {
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
ServiceAuthorizationMode::BearerPassthrough as i32
}
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip)
| Err(_) => ServiceAuthorizationMode::Strip as i32,
};
let workspace = endpoint.object_workspace();
let url =
service_routing::endpoint_url(&state.config, workspace, &endpoint.sandbox, &endpoint.name)
@@ -456,6 +485,100 @@ mod tests {
assert!(validate_endpoint_name("service", "Web", 28).is_err());
}
#[test]
fn authorization_mode_defaults_to_strip_and_rejects_unknown_values() {
assert_eq!(
validate_service_exposure_request("web", 8080, 0).unwrap(),
ServiceAuthorizationMode::Strip
);
assert_eq!(
validate_service_exposure_request(
"web",
8080,
ServiceAuthorizationMode::BearerPassthrough as i32,
)
.unwrap(),
ServiceAuthorizationMode::BearerPassthrough
);
assert_eq!(
validate_service_exposure_request("web", 8080, 99)
.unwrap_err()
.code(),
tonic::Code::InvalidArgument
);
}
#[tokio::test]
async fn unknown_authorization_mode_is_rejected_before_persistence() {
let state = test_server_state().await;
seed_sandbox(&state, "my-sandbox").await;
let error = handle_expose_service(
&state,
authed_request(ExposeServiceRequest {
sandbox: "my-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "web".to_string(),
target_port: 8080,
authorization_mode: 99,
..Default::default()
}),
)
.await
.unwrap_err();
assert_eq!(error.code(), tonic::Code::InvalidArgument);
assert!(
get_service_endpoint(&state, "default", "my-sandbox", "web")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn legacy_unspecified_authorization_mode_is_reported_as_strip() {
let state = test_server_state().await;
seed_sandbox(&state, "my-sandbox").await;
handle_expose_service(
&state,
authed_request(ExposeServiceRequest {
sandbox: "my-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "web".to_string(),
target_port: 8080,
..Default::default()
}),
)
.await
.unwrap();
let mut stored = get_service_endpoint(&state, "default", "my-sandbox", "web")
.await
.unwrap()
.unwrap();
stored.authorization_mode = ServiceAuthorizationMode::Unspecified as i32;
state.store.put_message(&stored).await.unwrap();
let response = handle_get_service(
&state,
authed_request(GetServiceRequest {
sandbox: "my-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "web".to_string(),
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(
response.endpoint.unwrap().authorization_mode(),
ServiceAuthorizationMode::Strip
);
}
#[tokio::test]
async fn endpoint_lifecycle_round_trip() {
let state = test_server_state().await;
@@ -472,12 +595,17 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(exposed.endpoint.as_ref().unwrap().target_port, 8080);
assert_eq!(
exposed.endpoint.as_ref().unwrap().authorization_mode(),
ServiceAuthorizationMode::Strip
);
let listed = handle_list_services(
&state,
@@ -511,6 +639,26 @@ mod tests {
.into_inner();
assert_eq!(fetched.endpoint.as_ref().unwrap().target_port, 8080);
let updated = handle_expose_service(
&state,
authed_request(ExposeServiceRequest {
sandbox: "my-sandbox".to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
name: "web".to_string(),
target_port: 9090,
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
..Default::default()
}),
)
.await
.unwrap()
.into_inner();
assert_eq!(updated.endpoint.as_ref().unwrap().target_port, 9090);
assert_eq!(
updated.endpoint.as_ref().unwrap().authorization_mode(),
ServiceAuthorizationMode::BearerPassthrough
);
let deleted = handle_delete_service(
&state,
authed_request(DeleteServiceRequest {
@@ -643,6 +791,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -661,6 +810,7 @@ mod tests {
name: "web".to_string(),
target_port: 9090,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -713,6 +863,7 @@ mod tests {
name: "web".to_string(),
target_port: 7070,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -732,6 +883,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -750,6 +902,7 @@ mod tests {
name: "web".to_string(),
target_port: 9090,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -840,6 +993,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -856,6 +1010,7 @@ mod tests {
name: "web".to_string(),
target_port: 9090,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
@@ -999,6 +1154,7 @@ mod tests {
name: "api".to_string(),
target_port: 3000,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}),
)
.await
+276 -15
View File
@@ -11,7 +11,9 @@ use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header
use hyper_util::rt::TokioIo;
use openshell_core::ObjectId;
use openshell_core::config::ServiceRoutingConfig;
use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open};
use openshell_core::proto::{
Sandbox, SandboxPhase, ServiceAuthorizationMode, ServiceEndpoint, TcpRelayTarget, relay_open,
};
use openshell_ocsf::{
ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, EventContext,
HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder,
@@ -428,6 +430,19 @@ async fn proxy_to_endpoint(
);
return Err(err);
}
let authorization_mode = effective_authorization_mode(endpoint.authorization_mode);
if validate_application_authorization(&req, authorization_mode).is_err() {
let err = ServiceRouteError::invalid_request();
emit_service_http_failure(
&state,
&req,
&sandbox_name,
&service_name,
Some(&endpoint),
&err,
);
return Err(err);
}
let websocket_upgrade = is_websocket_upgrade(&req);
let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut req));
@@ -446,7 +461,7 @@ async fn proxy_to_endpoint(
None => open_upstream(&state, &sandbox, &endpoint, target_port, websocket_upgrade).await?,
};
let upstream = build_upstream_request(req, target_port, websocket_upgrade)?;
let upstream = build_upstream_request(req, target_port, websocket_upgrade, authorization_mode)?;
let replay = reused.then(|| replayable_request(&upstream)).flatten();
let first_attempt = if reused {
sender.try_send_request(upstream).await.map_err(|mut err| {
@@ -626,6 +641,7 @@ fn build_upstream_request(
req: Request<Body>,
target_port: u16,
preserve_upgrade_headers: bool,
authorization_mode: ServiceAuthorizationMode,
) -> Result<Request<Body>, ServiceRouteError> {
let (parts, body) = req.into_parts();
let path = parts.uri.path_and_query().map_or("/", |path| path.as_str());
@@ -644,7 +660,7 @@ fn build_upstream_request(
for (name, value) in &parts.headers {
if (is_hop_by_hop_header(name)
&& !(preserve_upgrade_headers && is_websocket_hop_by_hop_header(name)))
|| is_gateway_auth_header(name)
|| is_gateway_auth_header(name, authorization_mode)
{
continue;
}
@@ -724,15 +740,83 @@ fn is_websocket_hop_by_hop_header(name: &header::HeaderName) -> bool {
matches!(name.as_str(), "connection" | "upgrade")
}
fn is_gateway_auth_header(name: &header::HeaderName) -> bool {
matches!(
name.as_str(),
"authorization"
| "cf-access-jwt-assertion"
| "x-forwarded-client-cert"
| "x-ssl-client-cert"
| "x-client-cert"
)
pub fn effective_authorization_mode(value: i32) -> ServiceAuthorizationMode {
match ServiceAuthorizationMode::try_from(value) {
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
ServiceAuthorizationMode::BearerPassthrough
}
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) | Err(_) => {
ServiceAuthorizationMode::Strip
}
}
}
fn authorization_mode_label(value: i32) -> &'static str {
match effective_authorization_mode(value) {
ServiceAuthorizationMode::BearerPassthrough => "bearer_passthrough",
ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip => "strip",
}
}
fn validate_application_authorization<B>(
req: &Request<B>,
authorization_mode: ServiceAuthorizationMode,
) -> Result<(), ServiceRouteError> {
if authorization_mode != ServiceAuthorizationMode::BearerPassthrough {
return Ok(());
}
let mut values = req.headers().get_all(header::AUTHORIZATION).iter();
let Some(value) = values.next() else {
return Ok(());
};
if values.next().is_some() {
return Err(ServiceRouteError::invalid_request());
}
let value = value
.to_str()
.map_err(|_| ServiceRouteError::invalid_request())?;
let Some((scheme, credential)) = value.split_once(' ') else {
return Err(ServiceRouteError::invalid_request());
};
let credential = credential.trim_start_matches(' ');
if !scheme.eq_ignore_ascii_case("bearer") || !is_bearer_token68(credential) {
return Err(ServiceRouteError::invalid_request());
}
Ok(())
}
fn is_bearer_token68(value: &str) -> bool {
let mut saw_data = false;
let mut saw_padding = false;
for byte in value.bytes() {
if byte == b'=' {
saw_padding = true;
} else if !saw_padding
&& (byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/'))
{
saw_data = true;
} else {
return false;
}
}
saw_data
}
fn is_gateway_auth_header(
name: &header::HeaderName,
authorization_mode: ServiceAuthorizationMode,
) -> bool {
match name.as_str() {
"authorization" => authorization_mode != ServiceAuthorizationMode::BearerPassthrough,
"cf-access-jwt-assertion"
| "x-forwarded-client-cert"
| "x-ssl-client-cert"
| "x-client-cert" => true,
_ => false,
}
}
fn sanitize_cookie_header(value: &HeaderValue) -> Option<HeaderValue> {
@@ -830,7 +914,11 @@ fn build_service_endpoint_config_event(
))
.unmapped("endpoint_name", endpoint_name(endpoint))
.unmapped("service_name", endpoint.name.clone())
.unmapped("target_port", u64::from(endpoint.target_port));
.unmapped("target_port", u64::from(endpoint.target_port))
.unmapped(
"authorization_mode",
authorization_mode_label(endpoint.authorization_mode),
);
if !url.is_empty() {
builder = builder.unmapped("url", url.to_string());
@@ -849,6 +937,10 @@ fn build_service_endpoint_delete_event(endpoint: &ServiceEndpoint) -> OcsfEvent
.unmapped("endpoint_name", endpoint_name(endpoint))
.unmapped("service_name", endpoint.name.clone())
.unmapped("target_port", u64::from(endpoint.target_port))
.unmapped(
"authorization_mode",
authorization_mode_label(endpoint.authorization_mode),
)
.build()
}
@@ -990,6 +1082,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
}
}
@@ -1233,6 +1326,7 @@ mod tests {
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
assert_eq!(json["unmapped"]["service_name"], "web");
assert_eq!(json["unmapped"]["target_port"], 8080);
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
assert!(
event
.format_shorthand()
@@ -1249,6 +1343,7 @@ mod tests {
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
assert_eq!(json["unmapped"]["service_name"], "web");
assert_eq!(json["unmapped"]["target_port"], 8080);
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
assert!(
event
.format_shorthand()
@@ -1309,7 +1404,8 @@ mod tests {
.body(Body::empty())
.unwrap();
let upstream = build_upstream_request(request, 8080, false).unwrap();
let upstream =
build_upstream_request(request, 8080, false, ServiceAuthorizationMode::Strip).unwrap();
assert_eq!(upstream.uri(), "/path");
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
@@ -1322,6 +1418,145 @@ mod tests {
assert_eq!(upstream.headers()["x-app-header"], "kept");
}
#[test]
fn unspecified_endpoint_authorization_mode_strips_authorization() {
let request = Request::builder()
.uri("/path")
.header(header::AUTHORIZATION, "Bearer application-token")
.body(Body::empty())
.unwrap();
let mode = effective_authorization_mode(ServiceAuthorizationMode::Unspecified as i32);
validate_application_authorization(&request, mode).unwrap();
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
assert_eq!(mode, ServiceAuthorizationMode::Strip);
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
}
#[test]
fn bearer_passthrough_preserves_valid_authorization_and_strips_gateway_identity() {
let request = Request::builder()
.uri("/path")
.header(header::AUTHORIZATION, "bEaReR application-token")
.header("cf-access-jwt-assertion", "edge-token")
.header("x-forwarded-client-cert", "cert")
.header(header::PROXY_AUTHORIZATION, "Basic proxy-secret")
.header(
header::COOKIE,
"theme=dark; CF_Authorization=edge-cookie; app=session",
)
.body(Body::empty())
.unwrap();
let mode = ServiceAuthorizationMode::BearerPassthrough;
validate_application_authorization(&request, mode).unwrap();
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
assert_eq!(
upstream.headers()[header::AUTHORIZATION],
"bEaReR application-token"
);
assert!(!upstream.headers().contains_key("cf-access-jwt-assertion"));
assert!(!upstream.headers().contains_key("x-forwarded-client-cert"));
assert!(!upstream.headers().contains_key(header::PROXY_AUTHORIZATION));
assert_eq!(
upstream.headers()[header::COOKIE],
"theme=dark; app=session"
);
}
#[test]
fn bearer_passthrough_allows_missing_authorization() {
let request = Request::builder().uri("/path").body(Body::empty()).unwrap();
let mode = ServiceAuthorizationMode::BearerPassthrough;
validate_application_authorization(&request, mode).unwrap();
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
}
#[test]
fn bearer_passthrough_rejects_ambiguous_or_malformed_authorization() {
for value in [
"",
"Bearer",
"Bearer ",
"Basic abc",
"Bearer abc extra",
"Bearer abc,def",
"Bearer abc=def",
"Bearer\tabc",
" Bearer abc",
] {
let request = Request::builder()
.uri("/path")
.header(header::AUTHORIZATION, value)
.body(Body::empty())
.unwrap();
assert!(
validate_application_authorization(
&request,
ServiceAuthorizationMode::BearerPassthrough,
)
.is_err()
);
}
for value in ["Bearer abc", "bearer abc-._~+/==", "Bearer abc"] {
let request = Request::builder()
.uri("/path")
.header(header::AUTHORIZATION, value)
.body(Body::empty())
.unwrap();
validate_application_authorization(
&request,
ServiceAuthorizationMode::BearerPassthrough,
)
.unwrap();
}
let mut request = Request::builder().uri("/path").body(Body::empty()).unwrap();
request.headers_mut().append(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer first"),
);
request.headers_mut().append(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer second"),
);
assert!(
validate_application_authorization(
&request,
ServiceAuthorizationMode::BearerPassthrough,
)
.is_err()
);
}
#[test]
fn authorization_value_is_not_in_service_routing_events() {
const SENTINEL: &str = "never-log-this-capability";
let request = Request::builder()
.uri("/private")
.header(header::AUTHORIZATION, format!("Bearer {SENTINEL}"))
.body(Body::empty())
.unwrap();
let err = ServiceRouteError::invalid_request();
let event = build_service_http_failure_event(
18080,
&request,
"my-sandbox",
"web",
Some(&endpoint()),
&err,
);
assert!(!event.to_json().unwrap().to_string().contains(SENTINEL));
assert!(!event.format_shorthand().contains(SENTINEL));
}
#[test]
fn detects_websocket_upgrade_request() {
let request = Request::builder()
@@ -1346,7 +1581,8 @@ mod tests {
.body(Body::empty())
.unwrap();
let upstream = build_upstream_request(request, 8080, true).unwrap();
let upstream =
build_upstream_request(request, 8080, true, ServiceAuthorizationMode::Strip).unwrap();
assert_eq!(upstream.uri(), "/chat?session=main");
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
@@ -1355,6 +1591,30 @@ mod tests {
assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080");
}
#[test]
fn bearer_passthrough_preserves_authorization_on_websocket_upgrade() {
let request = Request::builder()
.method(Method::GET)
.uri("/chat")
.header(header::CONNECTION, "Upgrade")
.header(header::UPGRADE, "websocket")
.header("sec-websocket-key", "abc")
.header(header::AUTHORIZATION, "Bearer application-token")
.body(Body::empty())
.unwrap();
let mode = ServiceAuthorizationMode::BearerPassthrough;
validate_application_authorization(&request, mode).unwrap();
let upstream = build_upstream_request(request, 8080, true, mode).unwrap();
assert_eq!(
upstream.headers()[header::AUTHORIZATION],
"Bearer application-token"
);
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
assert_eq!(upstream.headers()[header::UPGRADE], "websocket");
}
#[tokio::test]
async fn load_endpoint_uses_workspace_for_lookup() {
let store = crate::persistence::test_store().await;
@@ -1375,6 +1635,7 @@ mod tests {
name: "web".to_string(),
target_port: 8080,
domain: true,
authorization_mode: ServiceAuthorizationMode::Strip as i32,
};
store.put_message(&ep).await.unwrap();
+34 -6
View File
@@ -126,15 +126,19 @@ mod tests {
// inventories; the provider-environment file map is public-only. The
// request has no provider-file capability field: older supervisors ignore
// the additive file map while retaining the rest of the response.
// Service authorization also extends both schemas additively. Legacy
// payloads retain the safe Strip default.
const PUBLIC_RPC_SCHEMA_SHA256: &str =
"2ed66dbc38c60eb96c7461c76d02813c177facfad93753b180534477270ad240";
"2e156c6ad3c8eb51bcd30dc13b173fe339b38207a1b1f98f7be2e0cad8e3bd45";
const DURABLE_SCHEMA_SHA256: &str =
"399737f2a367d2e3a9d78cf84e2a97eef041835554599790788e4bbf318116c3";
"38165d9d76f49fcfe98a12f241e032838a2376c1d1a87ea2796fd33b9b1a3541";
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
"d3c444ecdb42306af8a81791481fdfc147ddc54bf344e1c8e69bd06745c6cc3c";
"761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3";
// A persisted Sandbox without endpoint status retains its lifecycle fields;
// the absent repeated field decodes empty and needs no database rewrite.
const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964";
// ServiceEndpoint encoded before authorization_mode field 7 existed.
const SERVICE_ENDPOINT_WITHOUT_AUTHORIZATION_MODE: &str = "0a260a0b656e64706f696e742d6964120c73616e64626f782d2d77656228073a0764656661756c74120a73616e64626f782d69641a0773616e64626f78220377656228903f3001";
// Synthetic payloads generated with the public declarations at v0.0.116,
// before their relocation into openshell.storage.v1. Values are deliberately
// non-secret and the ordinary protobuf bytes contain no package names.
@@ -594,9 +598,9 @@ mod tests {
overlap_hash.as_str(),
),
(
(306, 26),
(93, 20),
(81, 20),
(306, 27),
(93, 21),
(81, 21),
PUBLIC_RPC_SCHEMA_SHA256,
DURABLE_SCHEMA_SHA256,
PUBLIC_DURABLE_OVERLAP_SHA256
@@ -605,6 +609,30 @@ mod tests {
);
}
#[test]
fn service_endpoint_without_authorization_mode_decodes_as_strip() {
use openshell_core::proto::{ServiceAuthorizationMode, ServiceEndpoint};
let endpoint = ServiceEndpoint::decode(
legacy_bytes(SERVICE_ENDPOINT_WITHOUT_AUTHORIZATION_MODE).as_slice(),
)
.expect("stored service endpoint without authorization mode must decode");
assert_eq!(endpoint.sandbox_id, "sandbox-id");
assert_eq!(endpoint.sandbox, "sandbox");
assert_eq!(endpoint.name, "web");
assert_eq!(endpoint.target_port, 8080);
assert!(endpoint.domain);
assert_eq!(
endpoint.authorization_mode(),
ServiceAuthorizationMode::Unspecified
);
assert_eq!(
crate::service_routing::effective_authorization_mode(endpoint.authorization_mode),
ServiceAuthorizationMode::Strip
);
}
#[test]
fn pre_readiness_sandbox_spec_decodes_with_initial_attachment_epoch() {
let spec = openshell_core::proto::SandboxSpec::decode(
+81 -6
View File
@@ -478,13 +478,16 @@ name selects the unnamed endpoint. The returned sandbox includes a service URL
map keyed by those names; use the empty key for the unnamed endpoint:
```python
from openshell import SandboxClient, ServiceExposure
from openshell import SandboxClient, ServiceAuthorizationMode, ServiceExposure
with SandboxClient.from_active_cluster() as client:
sandbox = client.create(
workspace="default",
name="app-server",
service_exposures=[ServiceExposure(target_port=4500)],
service_exposures=[ServiceExposure(
target_port=4500,
authorization_mode=ServiceAuthorizationMode.BEARER_PASSTHROUGH,
)],
)
print(sandbox.service_urls[""])
```
@@ -493,7 +496,10 @@ with SandboxClient.from_active_cluster() as client:
const sandbox = await client.sandbox.create({
name: 'app-server',
image: 'base',
serviceExposures: [{ targetPort: 4500 }],
serviceExposures: [{
targetPort: 4500,
authorizationMode: ServiceAuthorizationMode.BearerPassthrough,
}],
})
console.log(sandbox.serviceUrls[''])
```
@@ -502,7 +508,10 @@ console.log(sandbox.serviceUrls[''])
sandbox, err := client.Sandboxes().Create(
ctx, "default", "app-server", spec, nil,
v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{
{TargetPort: 4500},
{
TargetPort: 4500,
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
},
}},
)
fmt.Println(sandbox.ServiceURLs[""])
@@ -514,6 +523,7 @@ let sandbox = client.create_sandbox(openshell_sdk::SandboxSpec {
service_exposures: vec![openshell_sdk::ServiceExposure {
service: String::new(),
target_port: 4500,
authorization_mode: openshell_sdk::ServiceAuthorizationMode::BearerPassthrough,
}],
..Default::default()
}).await?;
@@ -532,6 +542,63 @@ Pass an optional service name to create a named service URL:
openshell service expose my-sandbox 8080 web
```
OpenShell strips the incoming `Authorization` header by default. Opt a service
into forwarding one valid bearer credential unchanged when the application
performs its own authentication:
```shell
openshell service expose my-sandbox 4500 \
--authorization-mode bearer-passthrough
```
For a create-time exposure, use both flags:
```shell
openshell sandbox create \
--expose 4500 \
--expose-authorization-mode bearer-passthrough \
--detach \
-- ./authenticated-server
```
In `bearer-passthrough` mode, OpenShell accepts zero or one application
`Authorization` header. When present, it must contain a nonempty Bearer
credential. Missing credentials reach the application so it can return its own
authentication response. Duplicate, Basic, and malformed credentials fail with
`400 Bad Request`. Gateway and edge identity headers, proxy authorization, and
edge authentication cookies remain stripped.
<Warning>
Bearer passthrough delivers the caller's credential to the sandbox service.
Enable it only when that service is trusted to receive the credential. Exposed
services still use the gateway listener and its TLS configuration in this
release.
</Warning>
For example, Codex App Server can keep the raw capability outside the sandbox
and receive only its SHA-256 verifier:
```shell
APP_SERVER_TOKEN="$(openssl rand -hex 32)"
APP_SERVER_TOKEN_SHA256="$(printf %s "$APP_SERVER_TOKEN" | openssl dgst -sha256 -hex | awk '{print $2}')"
openshell sandbox create \
--name codex-server \
--env "APP_SERVER_TOKEN_SHA256=$APP_SERVER_TOKEN_SHA256" \
--expose 4500 \
--expose-authorization-mode bearer-passthrough \
--detach \
-- codex app-server \
--listen ws://127.0.0.1:4500 \
--ws-auth capability-token \
--ws-token-sha256 "$APP_SERVER_TOKEN_SHA256"
```
Clients send `Authorization: Bearer $APP_SERVER_TOKEN` in the WebSocket
handshake. Codex's WebSocket transport is experimental and unsupported for
production workloads. It authenticates the handshake before the app-server
`initialize` request.
List exposed endpoints:
```shell
@@ -552,7 +619,8 @@ openshell service list my-sandbox --output yaml
```
Structured list output contains `services` and `next_page_token` fields. Each
record contains `workspace`, `sandbox`, `service`, `target_port`, and `url`.
record contains `workspace`, `sandbox`, `service`, `target_port`,
`authorization_mode`, and `url`.
The unnamed service uses an empty `service` string. Pass the returned token to
`--page-token` to continue. An empty result has an empty `services` collection.
@@ -571,7 +639,14 @@ openshell service delete my-sandbox
```
<Note>
Loopback gateways return local `openshell.localhost` URLs. Remote gateways return HTTPS URLs that require normal gateway authentication. For gateway service-domain configuration, refer to [Manage Gateways](/how-it-works/gateways/overview#configure-service-forwarding).
Loopback gateways return local `openshell.localhost` URLs. Remote gateways
return HTTPS URLs on the gateway listener. Service routes bypass control-plane
RPC authorization and do not use OIDC or CLI login. Because they share the
listener in this release, its TLS configuration—including any required client
certificate—still applies. The application is responsible for authentication
enabled on its endpoint, and an upstream edge proxy may still apply its own
access policy. For gateway service-domain configuration, refer to
[Manage Gateways](/how-it-works/gateways/overview#configure-service-forwarding).
</Note>
## Monitor and Debug
+164 -7
View File
@@ -38,7 +38,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted",
"untrusted 0.7.1",
"zeroize",
]
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -814,7 +814,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -902,6 +902,8 @@ dependencies = [
"noyalib",
"prost",
"rand",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"serial_test",
@@ -909,6 +911,7 @@ dependencies = [
"sha2",
"tempfile",
"tokio",
"tokio-rustls",
"tokio-stream",
"tonic",
"tonic-prost",
@@ -1111,6 +1114,20 @@ dependencies = [
"bitflags",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1127,7 +1144,52 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
@@ -1317,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1326,6 +1388,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1375,7 +1443,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1452,7 +1520,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1466,6 +1534,16 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -1617,6 +1695,12 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -1738,6 +1822,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1747,6 +1840,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+8
View File
@@ -63,6 +63,11 @@ name = "custom_image"
path = "tests/custom_image.rs"
required-features = ["e2e-docker"]
[[test]]
name = "service_bearer_passthrough"
path = "tests/service_bearer_passthrough.rs"
required-features = ["e2e-docker"]
[[test]]
name = "rootfs_tar"
path = "tests/rootfs_tar.rs"
@@ -240,11 +245,14 @@ sha1 = "0.10"
sha2 = "0.10"
hex = "0.4"
rand = "0.9"
rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "aws_lc_rs"] }
rustls-pemfile = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
tonic = { version = "0.14", features = ["transport"] }
tonic-prost = "0.14"
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "aws_lc_rs"] }
tower = "0.5"
url = "2"
nix = { version = "0.29", features = ["process", "signal", "term", "user"] }
+6 -3
View File
@@ -477,9 +477,12 @@ impl Backend {
async fn spawn(&mut self, base: &str, tls_directory: &Path) -> Result<String, String> {
let tls_directory = tls_directory
.to_str()
.filter(|path| !path.contains([',', '\n', '\r']))
.filter(|path| !path.contains([':', '\n', '\r']))
.ok_or("fixture TLS mount path is invalid")?;
let mount = format!("type=bind,src={tls_directory},dst=/fixture-tls,readonly");
// Docker's structured `--mount` syntax cannot request SELinux
// relabeling. Both fixture backends mount this ephemeral directory, so
// use the shared `z` label rather than the single-container `Z` label.
let mount = format!("{tls_directory}:/fixture-tls:ro,z");
let namespace_label = format!("openshell.ai/sandbox-namespace={}", self.namespace);
let mut command = Command::from(self.engine.command());
command
@@ -501,7 +504,7 @@ impl Backend {
"--read-only",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--mount",
"--volume",
&mount,
"--entrypoint",
"/usr/bin/python3",
@@ -0,0 +1,344 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e-docker")]
//! Verifies application bearer authorization behavior through an exposed
//! `OpenShell` service.
use std::fs::File;
use std::io::BufReader;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt as _, Empty};
use hyper::client::conn::http1;
use hyper::{Request, StatusCode, header};
use hyper_util::rt::TokioIo;
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{ClientConfig, RootCertStore};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::time::{sleep, timeout};
use tokio_rustls::TlsConnector;
use url::Position;
const SERVICE_PORT: &str = "4500";
const BEARER_TOKEN: &str = "Bearer openshell-e2e-application-token";
const READY_TIMEOUT: Duration = Duration::from_secs(60);
const HEADER_ECHO_SERVER: &str = r#"
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = self.headers.get("Authorization", "").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, _format, *_args):
pass
ThreadingHTTPServer(("127.0.0.1", 4500), Handler).serve_forever()
"#;
async fn run_cli(args: &[&str]) -> Result<std::process::Output, String> {
let mut command = openshell_cmd();
command
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
.output()
.await
.map_err(|error| format!("failed to run openshell: {error}"))
}
enum ServiceTransport {
Http,
Https {
connector: TlsConnector,
server_name: ServerName<'static>,
},
}
struct ServiceTarget {
port: u16,
authority: String,
path: String,
transport: ServiceTransport,
}
impl ServiceTarget {
fn from_url(url: &str) -> Result<Self, String> {
let url = url::Url::parse(url).map_err(|error| format!("invalid service URL: {error}"))?;
let host = url
.host_str()
.ok_or_else(|| "service URL omitted its host".to_string())?;
let port = url
.port_or_known_default()
.ok_or_else(|| "service URL omitted its port".to_string())?;
let transport = match url.scheme() {
"http" => ServiceTransport::Http,
"https" => ServiceTransport::Https {
connector: e2e_tls_connector()?,
server_name: ServerName::try_from(host.to_string())
.map_err(|error| format!("invalid service TLS server name: {error}"))?,
},
scheme => return Err(format!("unsupported service URL scheme {scheme:?}")),
};
let authority = url[Position::BeforeHost..Position::AfterPort].to_string();
let path = url.query().map_or_else(
|| url.path().to_string(),
|query| format!("{}?{query}", url.path()),
);
Ok(Self {
port,
authority,
path,
transport,
})
}
}
fn e2e_mtls_dir() -> Result<PathBuf, String> {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.ok_or_else(|| "XDG_CONFIG_HOME is required for an HTTPS service URL".to_string())?;
let gateway = std::env::var("OPENSHELL_GATEWAY")
.map_err(|_| "OPENSHELL_GATEWAY is required for an HTTPS service URL".to_string())?;
Ok(PathBuf::from(config_home)
.join("openshell/gateways")
.join(gateway)
.join("mtls"))
}
fn load_certificates(
path: &Path,
description: &str,
) -> Result<Vec<CertificateDer<'static>>, String> {
let file = File::open(path)
.map_err(|error| format!("open {description} '{}': {error}", path.display()))?;
let mut reader = BufReader::new(file);
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("parse {description} '{}': {error}", path.display()))
}
fn load_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, String> {
let file = File::open(path)
.map_err(|error| format!("open client TLS key '{}': {error}", path.display()))?;
let mut reader = BufReader::new(file);
rustls_pemfile::private_key(&mut reader)
.map_err(|error| format!("parse client TLS key '{}': {error}", path.display()))?
.ok_or_else(|| format!("client TLS key '{}' is empty", path.display()))
}
fn e2e_tls_connector() -> Result<TlsConnector, String> {
let mtls_dir = e2e_mtls_dir()?;
let ca_path = mtls_dir.join("ca.crt");
let mut roots = RootCertStore::empty();
for certificate in load_certificates(&ca_path, "gateway CA certificate")? {
roots.add(certificate).map_err(|error| {
format!(
"add gateway CA certificate '{}': {error}",
ca_path.display()
)
})?;
}
let client_certificates =
load_certificates(&mtls_dir.join("tls.crt"), "client TLS certificate")?;
let client_key = load_private_key(&mtls_dir.join("tls.key"))?;
let config = ClientConfig::builder()
.with_root_certificates(roots)
.with_client_auth_cert(client_certificates, client_key)
.map_err(|error| format!("build e2e mTLS client configuration: {error}"))?;
Ok(TlsConnector::from(Arc::new(config)))
}
async fn request_over_stream<S>(
stream: S,
target: &ServiceTarget,
authorization: &str,
) -> Result<(StatusCode, String), String>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let (mut sender, connection) = http1::handshake(TokioIo::new(stream))
.await
.map_err(|error| format!("start HTTP connection: {error}"))?;
tokio::spawn(async move {
let _ = connection.await;
});
let request = Request::builder()
.uri(&target.path)
.header(header::HOST, &target.authority)
.header(header::AUTHORIZATION, authorization)
.body(Empty::<Bytes>::new())
.map_err(|error| format!("build service request: {error}"))?;
let response = sender
.send_request(request)
.await
.map_err(|error| format!("send service request: {error}"))?;
let status = response.status();
let body = response
.into_body()
.collect()
.await
.map_err(|error| format!("read service response: {error}"))?
.to_bytes();
let body = String::from_utf8(body.to_vec())
.map_err(|error| format!("service returned non-UTF-8 data: {error}"))?;
Ok((status, body))
}
async fn request_service(
target: &ServiceTarget,
authorization: &str,
) -> Result<(StatusCode, String), String> {
// The service hostname is a virtual routing authority. Dial the loopback
// gateway directly so the test does not depend on the host resolver
// recognizing arbitrary subdomains of `.localhost`.
let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, target.port))
.await
.map_err(|error| format!("connect to loopback service gateway: {error}"))?;
let _ = stream.set_nodelay(true);
match &target.transport {
ServiceTransport::Http => request_over_stream(stream, target, authorization).await,
ServiceTransport::Https {
connector,
server_name,
} => {
let stream = connector
.connect(server_name.clone(), stream)
.await
.map_err(|error| format!("start service TLS connection: {error}"))?;
request_over_stream(stream, target, authorization).await
}
}
}
async fn wait_for_authorization(url: &str, expected: &str) -> Result<(), String> {
// Parse the URL and load TLS material before the retry loop so permanent
// test-configuration errors fail immediately instead of looking like
// service-readiness timeouts.
let target = ServiceTarget::from_url(url)?;
let mut last_observation = "no request attempted".to_string();
let result = timeout(READY_TIMEOUT, async {
loop {
match request_service(&target, BEARER_TOKEN).await {
Ok((StatusCode::OK, body)) if body == expected => return Ok(()),
Ok((StatusCode::OK, body)) => {
return Err(format!(
"service received unexpected Authorization value: {body:?}"
));
}
Ok((status, body))
if matches!(
status,
StatusCode::BAD_GATEWAY
| StatusCode::PRECONDITION_FAILED
| StatusCode::SERVICE_UNAVAILABLE
) =>
{
last_observation =
format!("service returned retryable status {status} with body {body:?}");
sleep(Duration::from_millis(250)).await;
}
Err(error) => {
last_observation = error;
sleep(Duration::from_millis(250)).await;
}
Ok((status, body)) => {
return Err(format!(
"service returned unexpected status {status} with body {body:?}"
));
}
}
}
})
.await;
match result {
Ok(result) => result,
Err(_) => Err(format!(
"timed out waiting for the exposed service; last observation: {last_observation}"
)),
}
}
#[tokio::test]
async fn service_bearer_passthrough_preserves_authorization_header() {
let sandbox_name = format!("service-auth-{}", std::process::id());
let create = run_cli(&[
"sandbox",
"create",
"--name",
&sandbox_name,
"--from",
E2E_WORKLOAD_IMAGE,
"--expose",
SERVICE_PORT,
"--output",
"json",
"--detach",
"--no-tty",
"--",
"python3",
"-c",
HEADER_ECHO_SERVER,
])
.await
.expect("run sandbox create");
assert!(
create.status.success(),
"sandbox create failed with exit {:?}: {}",
create.status.code(),
String::from_utf8_lossy(&create.stderr)
);
let mut sandbox = SandboxGuard::manage_existing(sandbox_name.clone());
let created: Value = serde_json::from_slice(&create.stdout).expect("parse sandbox create JSON");
let service_url = created
.get("service_urls")
.and_then(|urls| urls.get(""))
.and_then(Value::as_str)
.expect("unnamed service URL in create response");
wait_for_authorization(service_url, "")
.await
.expect("default mode should strip Authorization");
let expose = run_cli(&[
"service",
"expose",
&sandbox_name,
SERVICE_PORT,
"--authorization-mode",
"bearer-passthrough",
])
.await
.expect("run service re-expose");
assert!(
expose.status.success(),
"service re-expose failed with exit {:?}: {}",
expose.status.code(),
String::from_utf8_lossy(&expose.stderr)
);
wait_for_authorization(service_url, BEARER_TOKEN)
.await
.expect("passthrough mode should preserve Authorization");
sandbox.cleanup().await;
}
+17
View File
@@ -1774,6 +1774,8 @@ message ExposeServiceRequest {
// Optional nonzero UUID for durable at-most-once admission. Successful results
// can be replayed for 24 hours; see the API errors and retries reference.
string request_id = 6;
// Application authorization behavior. Omission resolves to STRIP.
ServiceAuthorizationMode authorization_mode = 7;
}
// Request to fetch an exposed sandbox service endpoint.
@@ -1840,6 +1842,8 @@ message ServiceEndpoint {
uint32 target_port = 5;
// Whether browser-facing service routing is enabled for this endpoint.
bool domain = 6;
// Effective application authorization behavior for ingress requests.
ServiceAuthorizationMode authorization_mode = 7;
}
// Response containing a service endpoint and, when available, its local URL.
@@ -3785,4 +3789,17 @@ message SandboxServiceExposure {
string service = 1;
// Loopback TCP port inside the sandbox.
uint32 target_port = 2;
// Application authorization behavior. Omission resolves to STRIP.
ServiceAuthorizationMode authorization_mode = 3;
}
// Controls whether an exposed service receives the request's application
// Authorization header.
enum ServiceAuthorizationMode {
// Omission preserves the secure legacy behavior and resolves to STRIP.
SERVICE_AUTHORIZATION_MODE_UNSPECIFIED = 0;
// Remove Authorization before forwarding to the sandbox service.
SERVICE_AUTHORIZATION_MODE_STRIP = 1;
// Forward one syntactically valid bearer Authorization header unchanged.
SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH = 2;
}
+2
View File
@@ -21,6 +21,7 @@ from .sandbox import (
SandboxStatusRef,
SandboxTemplateClient,
SandboxWorkloadTemplateProvenanceRef,
ServiceAuthorizationMode,
ServiceExposure,
TlsConfig,
WorkspaceClient,
@@ -53,6 +54,7 @@ __all__ = [
"SandboxStatusRef",
"SandboxTemplateClient",
"SandboxWorkloadTemplateProvenanceRef",
"ServiceAuthorizationMode",
"ServiceExposure",
"TlsConfig",
"WorkspaceClient",
+15
View File
@@ -17,6 +17,7 @@ import threading
import time
from collections import namedtuple
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING, Any, Generic, Never, SupportsIndex, TypeVar, cast
from urllib.parse import urlparse
@@ -115,6 +116,12 @@ def _service_exposure_messages(
openshell_pb2.SandboxServiceExposure(
service=exposure.service,
target_port=exposure.target_port,
authorization_mode=(
openshell_pb2.SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH
if exposure.authorization_mode
== ServiceAuthorizationMode.BEARER_PASSTHROUGH
else openshell_pb2.SERVICE_AUTHORIZATION_MODE_STRIP
),
)
for exposure in exposures or ()
]
@@ -453,12 +460,20 @@ class SandboxStatusRef:
main_process_started_at_ms: int | None = None
class ServiceAuthorizationMode(IntEnum):
"""Handling for an incoming application Authorization header."""
STRIP = 1
BEARER_PASSTHROUGH = 2
@dataclass(frozen=True)
class ServiceExposure:
"""A loopback HTTP service to expose during sandbox creation."""
target_port: int
service: str = ""
authorization_mode: ServiceAuthorizationMode = ServiceAuthorizationMode.STRIP
class _ImmutableLabels(dict[str, str]):
+15 -3
View File
@@ -32,6 +32,7 @@ from openshell.sandbox import (
SandboxRef,
SandboxStatusRef,
SandboxTemplateClient,
ServiceAuthorizationMode,
ServiceExposure,
TlsConfig,
_atomic_replace,
@@ -2219,15 +2220,26 @@ def test_create_forwards_service_exposures() -> None:
name="app-server",
service_exposures=[
ServiceExposure(target_port=4500),
ServiceExposure(service="metrics", target_port=9090),
ServiceExposure(
service="metrics",
target_port=9090,
authorization_mode=ServiceAuthorizationMode.BEARER_PASSTHROUGH,
),
],
)
assert stub.create_request is not None
assert [
(exposure.service, exposure.target_port)
(exposure.service, exposure.target_port, exposure.authorization_mode)
for exposure in stub.create_request.service_exposures
] == [("", 4500), ("metrics", 9090)]
] == [
("", 4500, openshell_pb2.SERVICE_AUTHORIZATION_MODE_STRIP),
(
"metrics",
9090,
openshell_pb2.SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH,
),
]
assert dict(ref.service_urls) == {
"": "https://.example.test/",
"metrics": "https://metrics.example.test/",
+1 -1
View File
@@ -22,7 +22,7 @@ func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient {
}
// Expose returns Unimplemented.
func (c *fakeServiceClient) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) {
func (c *fakeServiceClient) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool, _ ...v1.ExposeServiceOptions) (*types.ServiceEndpoint, error) {
if c.closedFunc() {
return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"}
}
@@ -26,6 +26,7 @@ func ServiceEndpointFromProto(resp *pb.ServiceEndpointResponse) *types.ServiceEn
result.Name = ep.GetName()
result.TargetPort = ep.GetTargetPort()
result.Domain = ep.GetDomain()
result.AuthorizationMode = serviceAuthorizationModeFromProto(ep.GetAuthorizationMode())
if m := ep.GetMetadata(); m != nil {
result.ID = m.GetId()
@@ -48,12 +49,27 @@ func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointRespon
Id: se.ID,
Workspace: se.Workspace,
},
SandboxId: se.SandboxID,
Sandbox: se.Sandbox,
Name: se.Name,
TargetPort: se.TargetPort,
Domain: se.Domain,
SandboxId: se.SandboxID,
Sandbox: se.Sandbox,
Name: se.Name,
TargetPort: se.TargetPort,
Domain: se.Domain,
AuthorizationMode: serviceAuthorizationModeToProto(se.AuthorizationMode),
},
Url: se.URL,
}
}
func serviceAuthorizationModeToProto(mode types.ServiceAuthorizationMode) pb.ServiceAuthorizationMode {
if mode == types.ServiceAuthorizationModeBearerPassthrough {
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH
}
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP
}
func serviceAuthorizationModeFromProto(mode pb.ServiceAuthorizationMode) types.ServiceAuthorizationMode {
if mode == pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH {
return types.ServiceAuthorizationModeBearerPassthrough
}
return types.ServiceAuthorizationModeStrip
}
@@ -19,11 +19,12 @@ func TestServiceEndpointFromProto(t *testing.T) {
Metadata: &dm.ObjectMeta{
Id: "svc-1",
},
SandboxId: "sb-1",
Sandbox: "my-sandbox",
Name: "http-server",
TargetPort: 8080,
Domain: true,
SandboxId: "sb-1",
Sandbox: "my-sandbox",
Name: "http-server",
TargetPort: 8080,
Domain: true,
AuthorizationMode: pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH,
},
Url: "https://svc-1.example.com",
}
@@ -37,6 +38,7 @@ func TestServiceEndpointFromProto(t *testing.T) {
assert.Equal(t, "http-server", se.Name)
assert.Equal(t, uint32(8080), se.TargetPort)
assert.True(t, se.Domain)
assert.Equal(t, v1.ServiceAuthorizationModeBearerPassthrough, se.AuthorizationMode)
assert.Equal(t, "https://svc-1.example.com", se.URL)
}
@@ -78,13 +80,14 @@ func TestServiceEndpointFromProto_Nil(t *testing.T) {
func TestServiceEndpointToProto(t *testing.T) {
se := &v1.ServiceEndpoint{
ID: "svc-1",
SandboxID: "sb-1",
Sandbox: "my-sandbox",
Name: "http-server",
TargetPort: 8080,
Domain: true,
URL: "https://svc-1.example.com",
ID: "svc-1",
SandboxID: "sb-1",
Sandbox: "my-sandbox",
Name: "http-server",
TargetPort: 8080,
Domain: true,
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
URL: "https://svc-1.example.com",
}
resp := ServiceEndpointToProto(se)
@@ -98,6 +101,7 @@ func TestServiceEndpointToProto(t *testing.T) {
assert.Equal(t, "http-server", resp.Endpoint.Name)
assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort)
assert.True(t, resp.Endpoint.Domain)
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH, resp.Endpoint.AuthorizationMode)
assert.Equal(t, "https://svc-1.example.com", resp.Url)
}
@@ -108,13 +112,14 @@ func TestServiceEndpointToProto_Nil(t *testing.T) {
func TestServiceEndpointRoundTrip(t *testing.T) {
original := &v1.ServiceEndpoint{
ID: "svc-rt",
SandboxID: "sb-rt",
Sandbox: "round-trip",
Name: "web",
TargetPort: 9090,
Domain: false,
URL: "http://localhost:9090",
ID: "svc-rt",
SandboxID: "sb-rt",
Sandbox: "round-trip",
Name: "web",
TargetPort: 9090,
Domain: false,
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
URL: "http://localhost:9090",
}
proto := ServiceEndpointToProto(original)
@@ -127,5 +132,6 @@ func TestServiceEndpointRoundTrip(t *testing.T) {
assert.Equal(t, original.Name, back.Name)
assert.Equal(t, original.TargetPort, back.TargetPort)
assert.Equal(t, original.Domain, back.Domain)
assert.Equal(t, original.AuthorizationMode, back.AuthorizationMode)
assert.Equal(t, original.URL, back.URL)
}
+10 -2
View File
@@ -91,13 +91,21 @@ func serviceExposuresToProto(exposures []types.ServiceExposure) []*pb.SandboxSer
result := make([]*pb.SandboxServiceExposure, 0, len(exposures))
for _, exposure := range exposures {
result = append(result, &pb.SandboxServiceExposure{
Service: exposure.Service,
TargetPort: exposure.TargetPort,
Service: exposure.Service,
TargetPort: exposure.TargetPort,
AuthorizationMode: serviceAuthorizationModeToProto(exposure.AuthorizationMode),
})
}
return result
}
func serviceAuthorizationModeToProto(mode types.ServiceAuthorizationMode) pb.ServiceAuthorizationMode {
if mode == 0 {
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP
}
return pb.ServiceAuthorizationMode(mode)
}
func validateTemplateCreateSpec(spec *SandboxSpec) error {
if spec == nil {
return nil
+7 -1
View File
@@ -318,7 +318,11 @@ func TestSandboxCreate(t *testing.T) {
labels,
CreateOptions{ServiceExposures: []ServiceExposure{
{TargetPort: 4500},
{Service: "metrics", TargetPort: 9090},
{
Service: "metrics",
TargetPort: 9090,
AuthorizationMode: ServiceAuthorizationModeBearerPassthrough,
},
}},
)
@@ -334,7 +338,9 @@ func TestSandboxCreate(t *testing.T) {
}, result.ServiceURLs)
require.Len(t, mock.createRequest.GetServiceExposures(), 2)
assert.Equal(t, uint32(4500), mock.createRequest.GetServiceExposures()[0].GetTargetPort())
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP, mock.createRequest.GetServiceExposures()[0].GetAuthorizationMode())
assert.Equal(t, "metrics", mock.createRequest.GetServiceExposures()[1].GetService())
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH, mock.createRequest.GetServiceExposures()[1].GetAuthorizationMode())
}
func TestSandboxCreate_DefaultGPURequest(t *testing.T) {
+16 -1
View File
@@ -15,9 +15,24 @@ type ServiceEndpoint = types.ServiceEndpoint
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
type ServiceExposure = types.ServiceExposure
// ServiceAuthorizationMode controls handling of an incoming application Authorization header.
type ServiceAuthorizationMode = types.ServiceAuthorizationMode
const (
// ServiceAuthorizationModeStrip removes Authorization before proxying to the service.
ServiceAuthorizationModeStrip = types.ServiceAuthorizationModeStrip
// ServiceAuthorizationModeBearerPassthrough forwards one valid bearer credential unchanged.
ServiceAuthorizationModeBearerPassthrough = types.ServiceAuthorizationModeBearerPassthrough
)
// ExposeServiceOptions configures service exposure behavior.
type ExposeServiceOptions struct {
AuthorizationMode ServiceAuthorizationMode
}
// ServiceInterface defines operations for managing sandbox service endpoints.
type ServiceInterface interface {
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error)
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool, opts ...ExposeServiceOptions) (*ServiceEndpoint, error)
Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error)
List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error)
ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error)
+11 -6
View File
@@ -19,13 +19,18 @@ func newServiceClient(conn grpc.ClientConnInterface) *serviceClient {
return &serviceClient{client: pb.NewOpenShellClient(conn)}
}
func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) {
func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool, opts ...ExposeServiceOptions) (*ServiceEndpoint, error) {
authorizationMode := ServiceAuthorizationModeStrip
if len(opts) > 0 && opts[0].AuthorizationMode != 0 {
authorizationMode = opts[0].AuthorizationMode
}
resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{
Sandbox: sandboxName,
WorkspaceScope: namedWorkspaceScope(workspace),
Name: serviceName,
TargetPort: targetPort,
Domain: domain,
Sandbox: sandboxName,
WorkspaceScope: namedWorkspaceScope(workspace),
Name: serviceName,
TargetPort: targetPort,
Domain: domain,
AuthorizationMode: pb.ServiceAuthorizationMode(authorizationMode),
})
if err != nil {
return nil, converter.FromGRPCError(err)
+15 -5
View File
@@ -55,10 +55,11 @@ func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServi
Metadata: &dm.ObjectMeta{
Id: "ep-" + req.GetName(),
},
Sandbox: req.GetSandbox(),
Name: req.GetName(),
TargetPort: req.GetTargetPort(),
Domain: req.GetDomain(),
Sandbox: req.GetSandbox(),
Name: req.GetName(),
TargetPort: req.GetTargetPort(),
Domain: req.GetDomain(),
AuthorizationMode: req.GetAuthorizationMode(),
},
}
if req.GetDomain() {
@@ -150,7 +151,15 @@ func TestServiceExpose(t *testing.T) {
client, cleanup := setupServiceTest(t, mock)
defer cleanup()
ep, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true)
ep, err := client.Expose(
context.Background(),
"default",
"web-app",
"api",
8080,
true,
ExposeServiceOptions{AuthorizationMode: ServiceAuthorizationModeBearerPassthrough},
)
require.NoError(t, err)
require.NotNil(t, ep)
@@ -159,6 +168,7 @@ func TestServiceExpose(t *testing.T) {
assert.Equal(t, "api", ep.Name)
assert.Equal(t, uint32(8080), ep.TargetPort)
assert.True(t, ep.Domain)
assert.Equal(t, ServiceAuthorizationModeBearerPassthrough, ep.AuthorizationMode)
assert.Equal(t, "https://api.example.com", ep.URL)
}
+22 -10
View File
@@ -3,20 +3,32 @@
package types
// ServiceAuthorizationMode controls handling of an incoming application Authorization header.
type ServiceAuthorizationMode int32
const (
// ServiceAuthorizationModeStrip removes Authorization before proxying to the sandbox service.
ServiceAuthorizationModeStrip ServiceAuthorizationMode = 1
// ServiceAuthorizationModeBearerPassthrough forwards one valid bearer credential unchanged.
ServiceAuthorizationModeBearerPassthrough ServiceAuthorizationMode = 2
)
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
type ServiceExposure struct {
Service string
TargetPort uint32
Service string
TargetPort uint32
AuthorizationMode ServiceAuthorizationMode
}
// ServiceEndpoint represents an exposed HTTP service on a sandbox.
type ServiceEndpoint struct {
ID string
SandboxID string
Sandbox string
Name string
TargetPort uint32
Domain bool
URL string
Workspace string
ID string
SandboxID string
Sandbox string
Name string
TargetPort uint32
Domain bool
URL string
Workspace string
AuthorizationMode ServiceAuthorizationMode
}
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -20,9 +20,16 @@ import {
SandboxClient,
SandboxTemplateClient,
SCOPE_NAMES,
ServiceAuthorizationMode,
STATUS_NAMES,
} from './client.js';
import { OpenShell, SandboxPhase, SandboxRestartPolicy, ServiceStatus } from './gen/openshell_pb.js';
import {
OpenShell,
ServiceAuthorizationMode as ProtoServiceAuthorizationMode,
SandboxPhase,
SandboxRestartPolicy,
ServiceStatus,
} from './gen/openshell_pb.js';
import { PolicySource, SettingScope } from './gen/sandbox_pb.js';
import type { ExecInteractiveSession, ExecInteractiveSessionControl } from './index.js';
@@ -276,7 +283,9 @@ describe('exec / execStream', () => {
describe('create', () => {
it('sends create-time service exposures', async () => {
let created: { serviceExposures?: Array<{ service?: string; targetPort?: number }> } = {};
let created: {
serviceExposures?: Array<{ service?: string; targetPort?: number; authorizationMode?: number }>;
} = {};
const sandbox = client({
createSandbox: (req) => {
created = req;
@@ -292,12 +301,29 @@ describe('create', () => {
const result = await sandbox.create({
image: 'img',
serviceExposures: [{ targetPort: 4500 }, { service: 'metrics', targetPort: 9090 }],
serviceExposures: [
{ targetPort: 4500 },
{
service: 'metrics',
targetPort: 9090,
authorizationMode: ServiceAuthorizationMode.BearerPassthrough,
},
],
});
expect(created.serviceExposures?.map(({ service, targetPort }) => ({ service, targetPort }))).toEqual([
{ service: '', targetPort: 4500 },
{ service: 'metrics', targetPort: 9090 },
expect(
created.serviceExposures?.map(({ service, targetPort, authorizationMode }) => ({
service,
targetPort,
authorizationMode,
})),
).toEqual([
{ service: '', targetPort: 4500, authorizationMode: ProtoServiceAuthorizationMode.STRIP },
{
service: 'metrics',
targetPort: 9090,
authorizationMode: ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH,
},
]);
expect(result.serviceUrls).toEqual({
'': 'https://sb.example.test/',
+20
View File
@@ -22,6 +22,7 @@ import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './g
import {
type ExecSandboxInputSchema,
OpenShell,
ServiceAuthorizationMode as ProtoServiceAuthorizationMode,
SandboxPhase,
SandboxRestartPolicy,
type SandboxSpecSchema,
@@ -168,6 +169,23 @@ export interface ServiceExposure {
service?: string;
/** Loopback TCP port inside the sandbox. */
targetPort: number;
/** Handling for an incoming application Authorization header. */
authorizationMode?: ServiceAuthorizationMode;
}
export enum ServiceAuthorizationMode {
Strip = 'strip',
BearerPassthrough = 'bearer_passthrough',
}
function serviceAuthorizationModeToProto(mode: ServiceAuthorizationMode | undefined): ProtoServiceAuthorizationMode {
switch (mode) {
case ServiceAuthorizationMode.BearerPassthrough:
return ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH;
case ServiceAuthorizationMode.Strip:
case undefined:
return ProtoServiceAuthorizationMode.STRIP;
}
}
export interface SandboxFromTemplateSpec {
@@ -1023,6 +1041,7 @@ export class SandboxClient {
spec.serviceExposures?.map((exposure) => ({
service: exposure.service ?? '',
targetPort: exposure.targetPort,
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
})) ?? [],
});
return sandboxRef(resp.sandbox, resp.serviceUrls);
@@ -1049,6 +1068,7 @@ export class SandboxClient {
spec.serviceExposures?.map((exposure) => ({
service: exposure.service ?? '',
targetPort: exposure.targetPort,
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
})) ?? [],
});
return sandboxRef(resp.sandbox, resp.serviceUrls);
+8 -1
View File
@@ -53,7 +53,14 @@ export type {
WaitOptions,
WorkspaceListScope,
} from './client.js';
export { errorCode, OpenShellClient, Pager, SandboxClient, SandboxTemplateClient } from './client.js';
export {
errorCode,
OpenShellClient,
Pager,
SandboxClient,
SandboxTemplateClient,
ServiceAuthorizationMode,
} from './client.js';
export type { ErrorInfo, FieldViolation, SdkErrorCode } from './errors.js';
export { fromConnect, SdkError } from './errors.js';
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
+16 -1
View File
@@ -886,11 +886,13 @@ openshell sandbox create \
--name my-app \
--from my-app:latest \
--expose 8080 \
--expose-authorization-mode bearer-passthrough \
--detach \
-- ./start-server.sh
# Expose and manage an HTTP service through the gateway.
openshell service expose my-app 8080 web
openshell service expose my-app 8080 web \
--authorization-mode bearer-passthrough
openshell service list my-app
openshell service list my-app --output json
openshell service get my-app web
@@ -905,6 +907,19 @@ request and keeps the sandbox running. Add `--output json` for automation; the
result contains a `service_urls` map whose empty key is the unnamed endpoint.
Use `openshell service expose` after creation to add or update named endpoints.
Exposed services strip `Authorization` by default. Select
`bearer-passthrough` only when the application inside the sandbox authenticates
its own clients. This mode accepts either no `Authorization` header or exactly
one non-empty Bearer credential and forwards that value unchanged. It rejects
duplicate, malformed, or non-Bearer authorization before contacting the
application. The application remains responsible for validating the token, and
the raw token reaches the sandbox process, so never log it. Service routes
bypass control-plane RPC authorization, but they still use the gateway's
existing listener, domain routing, and TLS configuration, including any client
certificate requirement. See the published
[sandbox service documentation](https://docs.nvidia.com/openshell/latest/how-it-works/sandboxes/overview.md)
for the complete security contract.
Prefer loopback binds unless the user explicitly needs LAN-visible local access.
---
+164 -7
View File
@@ -38,7 +38,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted",
"untrusted 0.7.1",
"zeroize",
]
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -804,7 +804,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -903,12 +903,15 @@ dependencies = [
"noyalib",
"prost",
"rand",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"sha1",
"sha2",
"tempfile",
"tokio",
"tokio-rustls",
"tokio-stream",
"tonic",
"tonic-prost",
@@ -1122,6 +1125,20 @@ dependencies = [
"bitflags",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1138,7 +1155,52 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
@@ -1303,7 +1365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1312,6 +1374,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1361,7 +1429,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1438,7 +1506,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1452,6 +1520,16 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -1603,6 +1681,12 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -1724,6 +1808,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1733,6 +1826,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"