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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions crates/openshell-supervisor-middleware/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ rust-version.workspace = true
[dependencies]
openshell-core = { path = "../openshell-core", default-features = false }
openshell-extension-core = { path = "../openshell-extension-core" }
openshell-otel = { path = "../openshell-otel" }

miette = { workspace = true }
futures = { workspace = true }
Expand All @@ -26,6 +27,7 @@ tracing = { workspace = true }
[dev-dependencies]
openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" }
tokio-stream = { workspace = true, features = ["net"] }
tracing-subscriber = { workspace = true }

[lints]
workspace = true
64 changes: 54 additions & 10 deletions crates/openshell-supervisor-middleware/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,16 +255,28 @@ pub enum MiddlewareWorkAdmissionOutcome {
QueueExhausted,
}

/// Typed load-shedding signal emitted when the bounded middleware queue is full.
#[derive(Debug)]
pub struct MiddlewareAdmissionExhausted;

impl std::fmt::Display for MiddlewareAdmissionExhausted {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("middleware admission queue is full; refusing additional buffered work")
}
}

impl std::error::Error for MiddlewareAdmissionExhausted {}

impl miette::Diagnostic for MiddlewareAdmissionExhausted {}

impl MiddlewareWorkAdmissionOutcome {
/// Preserve the existing failure behavior for protocols whose outer layer
/// already translates middleware admission errors into a stable response
/// or typed termination.
pub fn into_admission(self) -> Result<MiddlewareWorkAdmission> {
match self {
Self::Admitted(admission) => Ok(admission),
Self::QueueExhausted => Err(miette!(
"middleware admission queue is full; refusing additional buffered work"
)),
Self::QueueExhausted => Err(MiddlewareAdmissionExhausted.into()),
}
}
}
Expand Down Expand Up @@ -497,6 +509,18 @@ pub struct ChainOutcome {
/// denied the request. Fail-closed service errors and transformed-body
/// policy denials are not represented as middleware decisions.
pub denial: Option<MiddlewareDenial>,
/// Typed reason the chain stopped. Consumers must use this instead of
/// inferring security semantics from free-form diagnostic text.
pub termination: ChainTermination,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainTermination {
Completed,
MiddlewareDenied,
MiddlewareFailure,
TransformedBodyPolicyDenied,
TransformedBodyPolicyEvaluationFailed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -1748,6 +1772,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand All @@ -1765,6 +1790,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand All @@ -1791,6 +1817,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand All @@ -1812,6 +1839,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand Down Expand Up @@ -1842,6 +1870,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand All @@ -1861,6 +1890,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand All @@ -1885,6 +1915,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand Down Expand Up @@ -1929,6 +1960,7 @@ impl ChainRunner {
metadata,
applied,
denial: Some(denial),
termination: ChainTermination::MiddlewareDenied,
});
}

Expand All @@ -1945,6 +1977,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand Down Expand Up @@ -1979,6 +2012,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::MiddlewareFailure,
});
}
}
Expand Down Expand Up @@ -2024,12 +2058,15 @@ impl ChainRunner {
if body_transformed
&& let TransformedBodyPolicy::Reevaluate(validate) = transformed_body_policy
{
let denied = match validate(&body) {
Ok(reason) => reason,
Err(error) => Some(format!(
"transformed_body_policy_evaluation_failed: {}",
safe_reason(&error.to_string())
)),
let (denied, termination) = match validate(&body) {
Ok(reason) => (reason, ChainTermination::TransformedBodyPolicyDenied),
Err(error) => (
Some(format!(
"transformed_body_policy_evaluation_failed: {}",
safe_reason(&error.to_string())
)),
ChainTermination::TransformedBodyPolicyEvaluationFailed,
),
};
if let Some(reason) = denied {
return Ok(ChainOutcome {
Expand All @@ -2041,6 +2078,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination,
});
}
}
Expand All @@ -2055,6 +2093,7 @@ impl ChainRunner {
metadata,
applied,
denial: None,
termination: ChainTermination::Completed,
})
}
}
Expand Down Expand Up @@ -6026,10 +6065,15 @@ mod tests {
overflow,
MiddlewareWorkAdmissionOutcome::QueueExhausted
));
runner
let error = runner
.reserve_middleware_work_admission()
.await
.expect_err("WebSocket callers retain their existing failure path");
assert!(
error
.downcast_ref::<MiddlewareAdmissionExhausted>()
.is_some()
);

drop(active);
for waiter in waiters {
Expand Down
85 changes: 83 additions & 2 deletions crates/openshell-supervisor-middleware/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,31 @@ use tonic::{Request, Response, Status};

use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES;

type ExtensionChannel = InterceptedService<Channel, BearerTokenInterceptor>;
type ExtensionChannel = InterceptedService<Channel, MiddlewareClientInterceptor>;

#[derive(Clone, Debug)]
struct MiddlewareClientInterceptor {
bearer: BearerTokenInterceptor,
trace_context: openshell_otel::TraceContextInterceptor,
}

impl MiddlewareClientInterceptor {
fn new(bearer: BearerTokenInterceptor) -> Self {
Self {
bearer,
trace_context: openshell_otel::TraceContextInterceptor,
}
}
}

impl tonic::service::Interceptor for MiddlewareClientInterceptor {
fn call(&mut self, mut request: Request<()>) -> std::result::Result<Request<()>, Status> {
request.metadata_mut().remove("traceparent");
request.metadata_mut().remove("tracestate");
let request = tonic::service::Interceptor::call(&mut self.bearer, request)?;
tonic::service::Interceptor::call(&mut self.trace_context, request)
}
}

/// Adapts the borrowed runtime request contract to the owned protobuf service
/// contract only when dispatch crosses a gRPC-shaped boundary.
Expand Down Expand Up @@ -147,7 +171,8 @@ impl RemoteMiddlewareService {
})?;
let interceptor =
bearer.map_or_else(BearerTokenInterceptor::disabled, |slot| slot.interceptor());
let channel = InterceptedService::new(channel, interceptor);
let channel =
InterceptedService::new(channel, MiddlewareClientInterceptor::new(interceptor));

Ok(Self {
client: SupervisorMiddlewareClient::new(channel.clone())
Expand Down Expand Up @@ -178,6 +203,7 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService {
client.validate_config(request).await
}

#[tracing::instrument(name = "openshell.middleware.rpc", skip_all, fields(otel.kind = "client", rpc.method = "EvaluateHttpRequest"))]
async fn evaluate_http_request(
&self,
request: Request<HttpRequestEvaluation>,
Expand All @@ -186,6 +212,7 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService {
client.evaluate_http_request(request).await
}

#[tracing::instrument(name = "openshell.middleware.rpc", skip_all, fields(otel.kind = "client", rpc.method = "EvaluateWebSocketSession"))]
async fn open_websocket_session(
&self,
receiver: tokio::sync::mpsc::Receiver<WebSocketSessionEvent>,
Expand All @@ -200,6 +227,7 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService {
Ok(Box::pin(responses))
}

#[tracing::instrument(name = "openshell.middleware.rpc", skip_all, fields(otel.kind = "client", rpc.method = "HttpResponsePreReturn"))]
async fn open_http_response_pre_return(
&self,
receiver: tokio::sync::mpsc::Receiver<HttpResponseEvent>,
Expand All @@ -214,3 +242,56 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService {
Ok(Box::pin(responses))
}
}

#[cfg(test)]
mod tests {
use tonic::service::Interceptor as _;
use tracing_subscriber::layer::SubscriberExt as _;

use super::*;

#[test]
fn client_interceptor_replaces_untrusted_trace_context_and_keeps_bearer() {
let slot = BearerTokenSlot::new("middleware-secret", i64::MAX).unwrap();
let mut interceptor = MiddlewareClientInterceptor::new(slot.interceptor());
let forged = "00-11111111111111111111111111111111-2222222222222222-01";
let provider = openshell_otel::SdkTracerProvider::builder().build();
let subscriber = tracing_subscriber::registry()
.with(openshell_otel::layer(&provider, "middleware-client-test"));
let (request, expected_traceparent) = tracing::subscriber::with_default(subscriber, || {
let span = tracing::info_span!("middleware.client");
let _entered = span.enter();
let expected_traceparent = openshell_otel::current_trace_context_carrier()
.and_then(|carrier| carrier.get("traceparent").cloned())
.expect("active host span has trace context");
let mut request = Request::new(());
request
.metadata_mut()
.insert("traceparent", forged.parse().unwrap());
request
.metadata_mut()
.insert("tracestate", "vendor=untrusted".parse().unwrap());
(interceptor.call(request).unwrap(), expected_traceparent)
});

assert_eq!(
request.metadata().get("authorization").unwrap(),
"Bearer middleware-secret"
);
let traceparent = request
.metadata()
.get("traceparent")
.expect("active host span is propagated")
.to_str()
.unwrap();
assert_ne!(traceparent, forged);
assert_eq!(traceparent, expected_traceparent);
assert_ne!(
request
.metadata()
.get("tracestate")
.and_then(|value| value.to_str().ok()),
Some("vendor=untrusted")
);
}
}
Loading
Loading