Skip to content

Commit 912a077

Browse files
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>
1 parent 374c035 commit 912a077

39 files changed

Lines changed: 2684 additions & 907 deletions

File tree

‎TESTING.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ Rust-based e2e tests that exercise the `openshell` CLI binary as a subprocess.
178178
They live in the `openshell-e2e` crate and use a shared harness for sandbox
179179
lifecycle management, output parsing, and cleanup.
180180

181+
Exposed service URLs use virtual hostnames for gateway routing. Host-side tests
182+
must connect the TCP socket directly to a reachable gateway listener address,
183+
normally loopback, and send the service URL authority in the HTTP `Host`
184+
header. Do not resolve `*.openshell.localhost`; resolver support for arbitrary
185+
`.localhost` subdomains varies across local and CI environments.
186+
187+
Treat the advertised service URL scheme as authoritative. For HTTPS, use the
188+
virtual service hostname for TLS SNI and the configured gateway trust roots.
189+
When the listener requires mTLS, present the active gateway client identity;
190+
the local e2e wrappers register these materials under
191+
`$XDG_CONFIG_HOME/openshell/gateways/$OPENSHELL_GATEWAY/mtls/`. Do not downgrade
192+
an HTTPS service URL to plaintext when dialing loopback. Parse the URL and load
193+
TLS material before entering a readiness loop so permanent configuration
194+
errors fail immediately. Retry only transient connection failures and
195+
documented readiness responses, and include the last observation in timeout
196+
diagnostics.
197+
198+
Verify exposed-service tests in both the default local mode and the
199+
CI-equivalent HTTPS mode:
200+
201+
```shell
202+
mise run e2e:rust
203+
OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false mise run e2e:rust
204+
```
205+
206+
When more than one test needs this behavior, put the transport in the shared
207+
Rust e2e harness and require callers to use it instead of duplicating DNS,
208+
HTTP `Host`, TLS SNI, and mTLS handling.
209+
181210
Suites:
182211

183212
- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.

‎crates/openshell-cli/src/main.rs‎

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use openshell_bootstrap::{
1919
use openshell_cli::completers;
2020
use openshell_cli::run;
2121
use openshell_cli::tls::TlsOptions;
22-
use openshell_core::proto::GpuResourceRequirements;
22+
use openshell_core::proto::{GpuResourceRequirements, ServiceAuthorizationMode};
2323

2424
/// Resolved gateway context: name + gateway endpoint.
2525
struct GatewayContext {
@@ -770,6 +770,22 @@ enum OutputFormat {
770770
Json,
771771
}
772772

773+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
774+
enum CliServiceAuthorizationMode {
775+
#[default]
776+
Strip,
777+
BearerPassthrough,
778+
}
779+
780+
impl From<CliServiceAuthorizationMode> for ServiceAuthorizationMode {
781+
fn from(value: CliServiceAuthorizationMode) -> Self {
782+
match value {
783+
CliServiceAuthorizationMode::Strip => Self::Strip,
784+
CliServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
785+
}
786+
}
787+
}
788+
773789
#[derive(Clone, Debug, ValueEnum)]
774790
enum CliProviderRefreshStrategy {
775791
Oauth2RefreshToken,
@@ -1520,6 +1536,10 @@ enum SandboxCommands {
15201536
)]
15211537
expose: Option<u16>,
15221538

1539+
/// Handling for an incoming application Authorization header.
1540+
#[arg(long, value_enum, default_value_t, requires = "expose")]
1541+
expose_authorization_mode: CliServiceAuthorizationMode,
1542+
15231543
/// Allocate a pseudo-terminal for the remote command.
15241544
/// Defaults to auto-detection (on when stdin and stdout are terminals).
15251545
/// Use --tty to force a PTY even when auto-detection fails, or
@@ -2369,6 +2389,10 @@ enum ServiceCommands {
23692389

23702390
/// Service name.
23712391
service: Option<String>,
2392+
2393+
/// Handling for an incoming application Authorization header.
2394+
#[arg(long, value_enum, default_value_t)]
2395+
authorization_mode: CliServiceAuthorizationMode,
23722396
},
23732397

23742398
/// List exposed sandbox service endpoints.
@@ -2914,13 +2938,15 @@ async fn run_async() -> Result<()> {
29142938
sandbox,
29152939
service,
29162940
target_port,
2941+
authorization_mode,
29172942
} => {
29182943
let service = service.unwrap_or_default();
29192944
run::service_expose(
29202945
&ctx.endpoint,
29212946
&sandbox,
29222947
&service,
29232948
target_port,
2949+
authorization_mode.into(),
29242950
&cli.workspace,
29252951
&tls,
29262952
)
@@ -3324,6 +3350,7 @@ async fn run_async() -> Result<()> {
33243350
policy,
33253351
forward,
33263352
expose,
3353+
expose_authorization_mode,
33273354
tty,
33283355
no_tty,
33293356
detach,
@@ -3421,6 +3448,7 @@ async fn run_async() -> Result<()> {
34213448
policy: policy.as_deref(),
34223449
forward,
34233450
expose,
3451+
expose_authorization_mode: expose_authorization_mode.into(),
34243452
command: &command,
34253453
tty_override,
34263454
auto_providers_override,
@@ -6716,9 +6744,19 @@ mod tests {
67166744

67176745
match cli.command {
67186746
Some(Commands::Sandbox {
6719-
command: Some(SandboxCommands::Create { expose, detach, .. }),
6747+
command:
6748+
Some(SandboxCommands::Create {
6749+
expose,
6750+
expose_authorization_mode,
6751+
detach,
6752+
..
6753+
}),
67206754
}) => {
67216755
assert_eq!(expose, Some(4500));
6756+
assert_eq!(
6757+
expose_authorization_mode,
6758+
CliServiceAuthorizationMode::Strip
6759+
);
67226760
assert!(detach);
67236761
}
67246762
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
@@ -6746,6 +6784,44 @@ mod tests {
67466784
assert!(result.is_err());
67476785
}
67486786

6787+
#[test]
6788+
fn sandbox_create_parses_bearer_passthrough_and_requires_expose() {
6789+
let cli = Cli::try_parse_from([
6790+
"openshell",
6791+
"sandbox",
6792+
"create",
6793+
"--expose",
6794+
"4500",
6795+
"--expose-authorization-mode",
6796+
"bearer-passthrough",
6797+
])
6798+
.expect("create-time authorization mode should parse with --expose");
6799+
match cli.command {
6800+
Some(Commands::Sandbox {
6801+
command:
6802+
Some(SandboxCommands::Create {
6803+
expose_authorization_mode,
6804+
..
6805+
}),
6806+
}) => assert_eq!(
6807+
expose_authorization_mode,
6808+
CliServiceAuthorizationMode::BearerPassthrough
6809+
),
6810+
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
6811+
}
6812+
6813+
assert!(
6814+
Cli::try_parse_from([
6815+
"openshell",
6816+
"sandbox",
6817+
"create",
6818+
"--expose-authorization-mode",
6819+
"bearer-passthrough",
6820+
])
6821+
.is_err()
6822+
);
6823+
}
6824+
67496825
#[test]
67506826
fn service_expose_accepts_positional_target_port_and_service() {
67516827
let cli = Cli::try_parse_from([
@@ -6765,11 +6841,13 @@ mod tests {
67656841
sandbox,
67666842
target_port,
67676843
service,
6844+
authorization_mode,
67686845
}),
67696846
}) => {
67706847
assert_eq!(sandbox, "my-sandbox");
67716848
assert_eq!(target_port, 8080);
67726849
assert_eq!(service.as_deref(), Some("api"));
6850+
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
67736851
}
67746852
other => panic!("expected service expose command, got: {other:?}"),
67756853
}
@@ -6787,16 +6865,45 @@ mod tests {
67876865
sandbox,
67886866
target_port,
67896867
service,
6868+
authorization_mode,
67906869
}),
67916870
}) => {
67926871
assert_eq!(sandbox, "my-sandbox");
67936872
assert_eq!(target_port, 8080);
67946873
assert_eq!(service, None);
6874+
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
67956875
}
67966876
other => panic!("expected service expose command, got: {other:?}"),
67976877
}
67986878
}
67996879

6880+
#[test]
6881+
fn service_expose_parses_bearer_passthrough() {
6882+
let cli = Cli::try_parse_from([
6883+
"openshell",
6884+
"service",
6885+
"expose",
6886+
"my-sandbox",
6887+
"4500",
6888+
"--authorization-mode",
6889+
"bearer-passthrough",
6890+
])
6891+
.expect("service authorization mode should parse");
6892+
6893+
match cli.command {
6894+
Some(Commands::Service {
6895+
command:
6896+
Some(ServiceCommands::Expose {
6897+
authorization_mode, ..
6898+
}),
6899+
}) => assert_eq!(
6900+
authorization_mode,
6901+
CliServiceAuthorizationMode::BearerPassthrough
6902+
),
6903+
other => panic!("expected service expose command, got: {other:?}"),
6904+
}
6905+
}
6906+
68006907
#[test]
68016908
fn service_alias_parses_service_commands() {
68026909
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
@@ -6809,6 +6916,7 @@ mod tests {
68096916
sandbox,
68106917
target_port,
68116918
service,
6919+
..
68126920
}),
68136921
}) => {
68146922
assert_eq!(sandbox, "my-sandbox");

0 commit comments

Comments
 (0)