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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ path = "src/main.rs"
openshell-crypto = { workspace = true }
openshell-bootstrap = { path = "../openshell-bootstrap" }
openshell-core = { path = "../openshell-core", default-features = false }
openshell-otel = { path = "../openshell-otel" }
openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-sdk = { path = "../openshell-sdk" }
Expand Down
93 changes: 86 additions & 7 deletions crates/openshell-cli/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::net::set_tcp_nodelay_best_effort;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_otel::EnvTraceContextInterceptor;
use rustls::{
RootCertStore,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
Expand All @@ -25,7 +26,30 @@ use tracing::debug;
use url::{Host, Url};

/// Concrete gRPC client type used by all commands.
pub type GrpcClient = OpenShellClient<InterceptedService<Channel, EdgeAuthInterceptor>>;
pub type GrpcClient = OpenShellClient<InterceptedService<Channel, GatewayInterceptor>>;

/// Interceptor stack applied to gateway requests from clients built by
/// [`grpc_client`].
///
/// Runs authentication header injection first, then passive W3C trace-context
/// forwarding so a CI pipeline's `TRACEPARENT` extends into the gateway's
/// spans. tonic allows a single interceptor per channel, so the two concerns
/// are composed here.
#[derive(Clone)]
pub struct GatewayInterceptor {
auth: EdgeAuthInterceptor,
trace: EnvTraceContextInterceptor,
}

impl tonic::service::Interceptor for GatewayInterceptor {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: GatewayInterceptor::call composes auth then trace, and interceptor_from_tls wires EnvTraceContextInterceptor::from_env into it, but no test exercises this composition; the tls.rs tests cover only routing and TLS helpers.

Why this matters: This is the integration point that makes CLI trace forwarding actually happen. If trace.call were dropped or the two interceptors swapped, the feature would silently no-op and no test would catch it.

Suggested fix: Add a test that sets both an edge token and a TRACEPARENT, runs the composed interceptor, and asserts both the auth header and the traceparent metadata are present.

Source: test-quality agent

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with gateway_interceptor_composes_auth_and_trace_forwarding: it builds the interceptor through the real interceptor_from_tls with an OIDC token plus TRACEPARENT/TRACESTATE set, runs the composed call, and asserts authorization, traceparent, and tracestate all land in the request metadata — so a dropped or swapped trace.call now fails the test.

One deviation from the suggestion: the CLI crate is #![forbid(unsafe_code)], so the test can't call std::env::set_var directly. It sets the env via the crate's existing temp-env dev-dependency under TEST_ENV_LOCK (the same pattern completers.rs uses), which adds no new dependencies or public API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. gateway_interceptor_composes_auth_and_trace_forwarding builds through the real interceptor_from_tls and asserts the auth header plus traceparent/tracestate are all present. Passes locally.

fn call(
&mut self,
request: tonic::Request<()>,
) -> std::result::Result<tonic::Request<()>, tonic::Status> {
let request = self.auth.call(request)?;
self.trace.call(request)
}
}

#[derive(Clone, Debug, Default)]
pub struct TlsOptions {
Expand Down Expand Up @@ -451,17 +475,22 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {

/// Build a gRPC [`OpenShellClient`].
///
/// When `tls.edge_token` is set, the returned client is wrapped with an
/// interceptor that injects authentication headers on every request.
/// Otherwise, standard mTLS is used (interceptor is a no-op).
/// The returned client carries a [`GatewayInterceptor`] that injects
/// authentication headers (when a token is set) and passively forwards the
/// `TRACEPARENT`/`TRACESTATE` trace context from the environment on every
/// request. With no token and no trace context, the interceptor is a no-op.
pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result<GrpcClient> {
let channel = build_channel(server, tls).await?;
let interceptor = interceptor_from_tls(tls)?;
Ok(OpenShellClient::with_interceptor(channel, interceptor))
}

fn interceptor_from_tls(tls: &TlsOptions) -> Result<EdgeAuthInterceptor> {
EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref())
fn interceptor_from_tls(tls: &TlsOptions) -> Result<GatewayInterceptor> {
let auth = EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref())?;
Ok(GatewayInterceptor {
auth,
trace: EnvTraceContextInterceptor::from_env(),
})
}

/// Gateway replica that owns the sandbox named in `response`, when it says.
Expand Down Expand Up @@ -510,10 +539,60 @@ pub fn retry_unrouted(replica: Option<&AsciiMetadataValue>, status: &tonic::Stat

#[cfg(test)]
mod tests {
use super::{owner_replica, retry_unrouted, routed_to, tls_server_name, with_owner_hint};
use super::{
TlsOptions, interceptor_from_tls, owner_replica, retry_unrouted, routed_to,
tls_server_name, with_owner_hint,
};
use crate::TEST_ENV_LOCK;
use openshell_core::replica_routing::{
OWNER_REPLICA_HEADER, OWNER_REQUEST_HEADER, ROUTE_REPLICA_HEADER,
};
use tonic::service::Interceptor as _;

#[test]
fn gateway_interceptor_composes_auth_and_trace_forwarding() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
temp_env::with_vars(
[
("TRACEPARENT", Some(traceparent)),
("TRACESTATE", Some("vendor=value")),
],
|| {
let tls = TlsOptions {
oidc_token: Some("test-token".to_string()),
..TlsOptions::new(None, None, None)
};
let mut interceptor = interceptor_from_tls(&tls).unwrap();

let request = interceptor.call(tonic::Request::new(())).unwrap();

assert_eq!(
request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer test-token")
);
assert_eq!(
request
.metadata()
.get("traceparent")
.and_then(|value| value.to_str().ok()),
Some(traceparent)
);
assert_eq!(
request
.metadata()
.get("tracestate")
.and_then(|value| value.to_str().ok()),
Some("vendor=value")
);
},
);
}

#[test]
fn owner_hint_is_requested_explicitly() {
Expand Down
6 changes: 3 additions & 3 deletions crates/openshell-otel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ pub use grpc::{
};
pub use ocsf::{OcsfOtlp, OcsfOtlpLayer};
pub use propagation::{
HeaderMapExtractor, MetadataMapInjector, TRACEPARENT_ENV, TRACESTATE_ENV,
TraceContextInterceptor, current_trace_context_carrier, current_trace_context_environment,
set_parent_from_environment, trace_context_environment,
EnvTraceContextInterceptor, HeaderMapExtractor, MetadataMapInjector, TRACEPARENT_ENV,
TRACESTATE_ENV, TraceContextInterceptor, current_trace_context_carrier,
current_trace_context_environment, set_parent_from_environment, trace_context_environment,
};

use opentelemetry::KeyValue;
Expand Down
Loading
Loading