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.

4 changes: 4 additions & 0 deletions crates/openshell-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configure tonic/prost protobuf code generation.
tonic_prost_build::configure()
.btree_map(".openshell.sandbox.protocol.v1")
.type_attribute(
".openshell.sandbox.v1.SupervisorMiddlewareBinding",
r#"#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")]"#,
)
.build_server(true)
.build_client(true)
.include_file("openshell.rs")
Expand Down
13 changes: 7 additions & 6 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,21 +541,22 @@ impl GatewayInterceptorConfig {
}
}

/// Operator policy for authorizing interceptor manifest bindings.
/// Operator policy for authorizing bindings advertised by an extension.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GatewayInterceptorBindingPolicy {
/// Preserve manifest-controlled binding discovery. Configured bindings
/// may narrow or disable manifest declarations.
/// Preserve manifest-controlled binding discovery.
#[default]
Dynamic,
/// Enable only configured RPC selectors and phases. Extra manifest
/// declarations are ignored.
/// Require and enable only configured bindings. Ignore extra declarations.
Allowlist,
/// Require configured and manifest RPC selectors and phases to match.
/// Require configured and manifest bindings to match exactly.
Exact,
}

/// The binding policy shared by gateway interceptors and supervisor middleware.
pub use GatewayInterceptorBindingPolicy as ExtensionBindingPolicy;

/// One configured source in the gateway's effective provider-profile catalog.
///
/// Deserialization is hand-written so that the removed `builtin` source is
Expand Down
10 changes: 5 additions & 5 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ pub mod transport_errors;
pub mod upload;

pub use config::{
AppArmorProfile, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride,
GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy,
GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig,
ImagePullPolicy, MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig,
UpstreamProxyConfig,
AppArmorProfile, Config, ExtensionBindingPolicy, GatewayAuthConfig,
GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig,
GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, GatewayJwtConfig,
GatewayProviderProfileSourceConfig, ImagePullPolicy, MtlsAuthConfig, OidcConfig,
PolicyValidationFailureMode, TlsConfig, UpstreamProxyConfig,
};
pub use dynamic_string_allowlist::DynamicStringAllowlist;
pub use error::{ComputeDriverError, Error, Result};
Expand Down
70 changes: 68 additions & 2 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf};

use base64::Engine as _;
use openshell_core::proto::SupervisorMiddlewareService;
use openshell_core::proto::{
SupervisorMiddlewareBinding, SupervisorMiddlewareBindingPolicy, SupervisorMiddlewareService,
};
use openshell_core::{
GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig,
ExtensionBindingPolicy, GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig,
GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig,
};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -502,6 +504,12 @@ pub struct MiddlewareServiceFileConfig {
/// Default RPC timeout using an integer with an `ms` or `s` suffix.
#[serde(default)]
pub timeout: Option<String>,
/// Which advertised operation/phase pairs the operator authorizes.
#[serde(default)]
pub binding_policy: ExtensionBindingPolicy,
/// Required operation/phase pairs for an allowlist or exact policy.
#[serde(default)]
pub bindings: Vec<SupervisorMiddlewareBinding>,
}

impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService {
Expand All @@ -521,9 +529,25 @@ impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService {
None => Vec::new(),
};

let binding_policy = match config.binding_policy {
// Preserve the wire config and revision of registrations using the default.
ExtensionBindingPolicy::Dynamic => SupervisorMiddlewareBindingPolicy::Unspecified,
ExtensionBindingPolicy::Allowlist => SupervisorMiddlewareBindingPolicy::Allowlist,
ExtensionBindingPolicy::Exact => SupervisorMiddlewareBindingPolicy::Exact,
};
// Binding order has no meaning and must not change the configuration revision.
// Keep duplicates so registration validation can report them.
let mut bindings: Vec<_> = config
.bindings
.iter()
.map(|binding| *binding as i32)
.collect();
bindings.sort_unstable();
Ok(Self {
name: config.name.clone(),
grpc_endpoint: config.grpc_endpoint.clone(),
binding_policy: binding_policy.into(),
bindings,
max_payload_bytes: config.max_payload_bytes,
request_timeout: config
.timeout
Expand Down Expand Up @@ -1468,6 +1492,8 @@ timeout = "2s"
allow_insecure_transport: false,
max_payload_bytes: 262_144,
timeout: Some("2s".into()),
binding_policy: ExtensionBindingPolicy::Dynamic,
bindings: vec![],
}]
);
let registration =
Expand All @@ -1485,6 +1511,40 @@ timeout = "2s"
);
}

#[test]
fn supervisor_middleware_binding_policy_reaches_wire_configuration() {
use SupervisorMiddlewareBinding::{HttpRequestPreCredentials, HttpResponsePreReturn};
use SupervisorMiddlewareBindingPolicy::{Allowlist, Unspecified};

let toml = r#"
name = "guard"
grpc_endpoint = "https://guard.example:50051"
max_payload_bytes = 1024
"#;
let parse = |text: &str| {
let config: MiddlewareServiceFileConfig = ::toml::from_str(text).unwrap();
SupervisorMiddlewareService::try_from(&config).unwrap()
};
let default = parse(toml);
assert_eq!(default.binding_policy, Unspecified as i32);
assert!(default.bindings.is_empty());

let registration = parse(&format!(
r#"{toml}
binding_policy = "allowlist"
bindings = ["http_response_pre_return", "http_request_pre_credentials"]
"#
));
assert_eq!(registration.binding_policy, Allowlist as i32);
assert_eq!(
registration.bindings,
[
HttpRequestPreCredentials as i32,
HttpResponsePreReturn as i32
]
);
}

#[test]
fn middleware_registration_defaults_audience_to_name() {
let mut config = MiddlewareServiceFileConfig {
Expand All @@ -1495,6 +1555,8 @@ timeout = "2s"
allow_insecure_transport: false,
max_payload_bytes: 262_144,
timeout: None,
binding_policy: ExtensionBindingPolicy::Dynamic,
bindings: vec![],
};

let registration = SupervisorMiddlewareService::try_from(&config).unwrap();
Expand Down Expand Up @@ -1527,6 +1589,8 @@ timeout = "2s"
allow_insecure_transport: false,
max_payload_bytes: 262_144,
timeout: None,
binding_policy: ExtensionBindingPolicy::Dynamic,
bindings: vec![],
};

let error = SupervisorMiddlewareService::try_from(&config)
Expand Down Expand Up @@ -1559,6 +1623,8 @@ timeout = "2s"
allow_insecure_transport: false,
max_payload_bytes: 262_144,
timeout: None,
binding_policy: ExtensionBindingPolicy::Dynamic,
bindings: vec![],
};

let error = SupervisorMiddlewareService::try_from(&config)
Expand Down
25 changes: 20 additions & 5 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22994,19 +22994,34 @@ mod tests {

#[test]
fn config_revision_changes_when_supervisor_middleware_services_change() {
use openshell_core::proto::{
SupervisorMiddlewareBinding, SupervisorMiddlewareBindingPolicy,
};

let policy = ProtoSandboxPolicy::default();
let settings = HashMap::new();
let service = openshell_core::proto::SupervisorMiddlewareService {
let mut service = openshell_core::proto::SupervisorMiddlewareService {
name: "local-guard".into(),
grpc_endpoint: "http://127.0.0.1:50051".into(),
max_payload_bytes: 1024,
..Default::default()
};

let without = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
let with =
compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[service]);
assert_ne!(without, with);
let revision = |services: &[openshell_core::proto::SupervisorMiddlewareService]| {
compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, services)
};
let without = revision(&[]);
let dynamic = revision(std::slice::from_ref(&service));
assert_ne!(without, dynamic);

service.binding_policy = SupervisorMiddlewareBindingPolicy::Allowlist.into();
service.bindings = vec![SupervisorMiddlewareBinding::HttpRequestPreCredentials.into()];
let request = revision(std::slice::from_ref(&service));
assert_ne!(dynamic, request);
service
.bindings
.push(SupervisorMiddlewareBinding::HttpResponsePreReturn.into());
assert_ne!(request, revision(&[service]));
}

#[test]
Expand Down
6 changes: 4 additions & 2 deletions crates/openshell-server/src/storage_proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ mod tests {
// Sandbox-wide grant reporting adds TokenGrantFailureObservation to both
// closures and TokenGrantStatus to public reports. Older rows have no grant
// record; absent report extensions retain compatibility without a rewrite.
// Supervisor middleware binding policy and selectors are public-only. An
// absent policy preserves dynamic discovery; older supervisors ignore both fields.
const PUBLIC_RPC_SCHEMA_SHA256: &str =
"a91e2244a2c1a8e369efe85bd0e4f2769f0f29f6263137280fe38e8a8da3cc6e";
"5ae09053b7d6a114971edc32770918d63ae5848bb9670135b67d7891dec1ccd5";
const DURABLE_SCHEMA_SHA256: &str =
"d4dc7abdb91b919db1f7be0d4652e30cbfda86f3b2ed9b59d5de1f76a2bf691d";
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
Expand Down Expand Up @@ -679,7 +681,7 @@ mod tests {
overlap_hash.as_str(),
),
(
(328, 29),
(328, 31),
(95, 22),
(83, 22),
PUBLIC_RPC_SCHEMA_SHA256,
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-middleware/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ prost-types = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tonic = { workspace = true, features = ["channel", "server", "tls-native-roots"] }
tracing = { workspace = true }

[dev-dependencies]
openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" }
Expand Down
134 changes: 134 additions & 0 deletions crates/openshell-supervisor-middleware/src/binding_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::collections::{BTreeMap, BTreeSet};

use miette::{Result, miette};
use openshell_core::proto::{
MiddlewareBinding, SupervisorMiddlewareBinding as Binding,
SupervisorMiddlewareBindingPolicy as Policy, SupervisorMiddlewareService,
};

/// A validated operator policy. The gateway sends the same policy to the
/// supervisor, which authorizes its own Describe rather than trusting the
/// service to advertise the same bindings to both callers.
pub enum BindingPolicy {
Dynamic,
Allowlist(BTreeSet<Binding>),
Exact(BTreeSet<Binding>),
}

impl TryFrom<&SupervisorMiddlewareService> for BindingPolicy {
type Error = miette::Report;

fn try_from(service: &SupervisorMiddlewareService) -> Result<Self> {
let name = &service.name;
let policy = Policy::try_from(service.binding_policy)
.map_err(|_| miette!("middleware '{name}' has unknown binding_policy"))?;
let mut bindings = BTreeSet::new();
for &value in &service.bindings {
let binding = Binding::try_from(value)
.ok()
.filter(|binding| *binding != Binding::Unspecified)
.ok_or_else(|| miette!("middleware '{name}' has invalid binding {value}"))?;
if !bindings.insert(binding) {
return Err(miette!("middleware '{name}' repeats binding {binding:?}"));
}
}
match policy {
// Gateways that predate binding policies omit both fields.
Policy::Unspecified | Policy::Dynamic if bindings.is_empty() => Ok(Self::Dynamic),
Policy::Unspecified | Policy::Dynamic => Err(miette!(
"middleware '{name}' with bindings must set binding_policy to allowlist or exact"
)),
Policy::Allowlist | Policy::Exact if bindings.is_empty() => Err(miette!(
"middleware '{name}' with {policy:?} binding_policy must configure bindings"
)),
Policy::Allowlist => Ok(Self::Allowlist(bindings)),
Policy::Exact => Ok(Self::Exact(bindings)),
}
}
}

impl BindingPolicy {
pub fn authorize(
&self,
source: &str,
advertised: &mut BTreeMap<Binding, MiddlewareBinding>,
) -> Result<()> {
let (Self::Allowlist(required) | Self::Exact(required)) = self else {
tracing::warn!(
"{source} uses dynamic binding discovery; the service controls its bindings"
);
return Ok(());
};
for binding in required {
if !advertised.contains_key(binding) {
return Err(miette!(
"{source} does not advertise required binding {binding:?}"
));
}
}
if let Self::Exact(_) = self
&& let Some(extra) = advertised
.keys()
.find(|binding| !required.contains(binding))
{
return Err(miette!(
"{source} advertises binding {extra:?} outside its exact policy"
));
}
advertised.retain(|binding, _| {
let authorized = required.contains(binding);
if !authorized {
tracing::info!("{source} binding {binding:?} is not in the allowlist, ignoring");
}
authorized
});
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn invalid_binding_policies_are_not_treated_as_dynamic() {
let request = Binding::HttpRequestPreCredentials as i32;
for (policy, bindings) in [
(1234, vec![]),
(Policy::Unspecified as i32, vec![request]),
(Policy::Dynamic as i32, vec![request]),
(Policy::Allowlist as i32, vec![]),
(Policy::Exact as i32, vec![]),
(Policy::Exact as i32, vec![0]),
(Policy::Allowlist as i32, vec![1234]),
(Policy::Allowlist as i32, vec![request, request]),
] {
let service = SupervisorMiddlewareService {
binding_policy: policy,
bindings,
..Default::default()
};
assert!(BindingPolicy::try_from(&service).is_err(), "{service:?}");
}
}

#[test]
fn exact_requires_the_same_set_not_just_a_subset() {
let request = Binding::HttpRequestPreCredentials;
let response = Binding::HttpResponsePreReturn;
let policy = BindingPolicy::Exact(BTreeSet::from([request]));
let matching = BTreeMap::from([(request, MiddlewareBinding::default())]);
policy.authorize("service", &mut matching.clone()).unwrap();

let mut extra = matching;
extra.insert(response, MiddlewareBinding::default());
let error = policy.authorize("service", &mut extra).unwrap_err();
assert!(
error.to_string().contains("outside its exact policy"),
"{error}"
);
}
}
Loading
Loading