From af845620095d2e02734b3eb5ff6956986da314c6 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 14:08:34 -0700 Subject: [PATCH 1/8] feat(gateway): add descriptor-driven interceptors Signed-off-by: Drew Newberry --- .gitignore | 1 + Cargo.lock | 20 + architecture/gateway.md | 11 + crates/openshell-core/src/config.rs | 91 + crates/openshell-core/src/lib.rs | 5 +- crates/openshell-core/src/proto/mod.rs | 17 + .../openshell-gateway-interceptors/Cargo.toml | 30 + .../openshell-gateway-interceptors/src/lib.rs | 1891 +++++++++++++++++ .../src/routes.rs | 154 ++ crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/cli.rs | 5 + crates/openshell-server/src/config_file.rs | 7 +- crates/openshell-server/src/lib.rs | 12 + crates/openshell-server/src/multiplex.rs | 173 +- docs/reference/gateway-config.mdx | 22 + examples/governance-interceptor/Cargo.lock | 1880 ++++++++++++++++ examples/governance-interceptor/Cargo.toml | 25 + examples/governance-interceptor/README.md | 64 + examples/governance-interceptor/policy.yaml | 40 + examples/governance-interceptor/smoke.sh | 272 +++ examples/governance-interceptor/src/main.rs | 802 +++++++ proto/gateway_interceptor.proto | 98 + 22 files changed, 5613 insertions(+), 8 deletions(-) create mode 100644 crates/openshell-gateway-interceptors/Cargo.toml create mode 100644 crates/openshell-gateway-interceptors/src/lib.rs create mode 100644 crates/openshell-gateway-interceptors/src/routes.rs create mode 100644 examples/governance-interceptor/Cargo.lock create mode 100644 examples/governance-interceptor/Cargo.toml create mode 100644 examples/governance-interceptor/README.md create mode 100644 examples/governance-interceptor/policy.yaml create mode 100755 examples/governance-interceptor/smoke.sh create mode 100644 examples/governance-interceptor/src/main.rs create mode 100644 proto/gateway_interceptor.proto diff --git a/.gitignore b/.gitignore index a3d613775..bcffeef02 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Build output /target/ e2e/rust/target/ +target/ debug/ release/ diff --git a/Cargo.lock b/Cargo.lock index f693acd66..eb6ab1443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3574,6 +3574,25 @@ dependencies = [ "zstd", ] +[[package]] +name = "openshell-gateway-interceptors" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "hyper-util", + "json-patch", + "metrics", + "openshell-core", + "prost", + "prost-types", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tonic", + "tower 0.5.3", + "tracing", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -3699,6 +3718,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-podman", + "openshell-gateway-interceptors", "openshell-ocsf", "openshell-policy", "openshell-prover", diff --git a/architecture/gateway.md b/architecture/gateway.md index 979422d7e..12ff0d40a 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -41,6 +41,17 @@ Operators can configure a gateway-wide gRPC request rate limit. The limit is applied only to gRPC API traffic after protocol multiplexing; health, metrics, and local sandbox-service HTTP routes are not rate limited by this control. +Gateway interceptors run in one middleware layer on the `openshell.v1.OpenShell` +gRPC service after authentication and before tonic dispatches to individual +handlers. At startup the gateway calls each configured interceptor's `Describe` +RPC, validates declared bindings against the compiled OpenShell descriptor set, +and builds an immutable execution plan. Unary OpenShell requests that are not +streaming, supervisor-facing, read-only, or introspection methods are decoded +through the descriptor set into protobuf JSON, evaluated through configured +phases, and re-encoded before the handler sees the request. This keeps +interception centralized: adding an interceptable unary RPC does not require +method-specific gateway instrumentation. + Supported auth modes: | Mode | Use | diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index eaaf1e4a0..df199d832 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -338,6 +338,9 @@ pub struct Config { /// Gateway user authentication behavior. pub auth: GatewayAuthConfig, + /// Disabled-by-default gateway interceptor service configs. + pub gateway_interceptors: Vec, + /// mTLS user authentication configuration. When enabled, a verified TLS /// client certificate can authenticate CLI/SDK callers as a /// `Principal::User`. This is for local single-user gateways only; @@ -494,6 +497,83 @@ pub struct GatewayAuthConfig { pub allow_unauthenticated_users: bool, } +/// One configured gateway interceptor service. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GatewayInterceptorConfig { + /// Operator-assigned instance name used in logs and config overrides. + pub name: String, + /// Interceptor gRPC endpoint. Supports `http://`, `https://`, and + /// `unix://` endpoints. + pub grpc_endpoint: String, + /// Deterministic service ordering. Lower values run first. + #[serde(default)] + pub order: i32, + /// Default failure policy for this configured service. + #[serde(default)] + pub failure_policy: Option, + /// RFC-style timeout string such as `500ms` or `2s`. + #[serde(default)] + pub timeout: Option, + /// Maximum accepted encoded `Evaluate` response size. + #[serde(default)] + pub max_response_bytes: Option, + /// Maximum JSON patches accepted from one evaluation result. + #[serde(default)] + pub max_patches: Option, + /// Optional binding overrides. Overrides may disable bindings or narrow + /// phases/selectors declared by the interceptor service. + #[serde(default)] + pub bindings: Vec, +} + +/// Failure behavior when an interceptor evaluation cannot produce a valid +/// result. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorFailurePolicy { + FailClosed, + FailOpen, + Ignore, +} + +/// Configured override for a manifest binding. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GatewayInterceptorBindingOverride { + /// Binding id from the interceptor manifest. + #[serde(default)] + pub id: Option, + /// Full selector form: `openshell.v1.OpenShell/CreateSandbox`. + #[serde(default)] + pub rpc: Option, + /// Structured selector service, e.g. `openshell.v1.OpenShell`. + #[serde(default)] + pub service: Option, + /// Structured selector method, e.g. `CreateSandbox`. + #[serde(default)] + pub method: Option, + /// Narrowed phase set. + #[serde(default)] + pub phases: Option>, + /// Disable the selected binding. + #[serde(default)] + pub disabled: bool, + /// Binding-specific failure policy override. + #[serde(default)] + pub failure_policy: Option, +} + +/// Config file phase names. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorPhaseConfig { + PreRequest, + ModifyOperation, + Validate, + PostCommit, +} + const fn default_jwks_ttl_secs() -> u64 { 3600 } @@ -555,6 +635,7 @@ impl Config { tls, oidc: None, auth: GatewayAuthConfig::default(), + gateway_interceptors: Vec::new(), mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, database_url: String::new(), @@ -641,6 +722,16 @@ impl Config { self } + /// Set configured gateway interceptors. + #[must_use] + pub fn with_gateway_interceptors(mut self, interceptors: I) -> Self + where + I: IntoIterator, + { + self.gateway_interceptors = interceptors.into_iter().collect(); + self + } + /// Return the effective gRPC rate limit, if fully configured and enabled. #[must_use] pub fn grpc_rate_limit(&self) -> Option<(u64, Duration)> { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 321296369..8830bce9a 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -38,8 +38,9 @@ pub mod telemetry; pub mod time; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, - TlsConfig, + ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, + GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e..2cd82ff76 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -79,7 +79,24 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod gateway_interceptor { + pub mod v1 { + include!(concat!( + env!("OUT_DIR"), + "/openshell.gateway_interceptor.v1.rs" + )); + } +} + pub use datamodel::v1::*; +pub use gateway_interceptor::v1::*; pub use inference::v1::*; pub use openshell::*; pub use sandbox::v1::*; diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml new file mode 100644 index 000000000..f38b055bb --- /dev/null +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-gateway-interceptors" +description = "Gateway interceptor framework for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +base64 = { workspace = true } +hyper-util = { workspace = true, features = ["client", "http1", "http2", "tokio"] } +json-patch = "1.4" +metrics = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tower = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs new file mode 100644 index 000000000..2460c499b --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -0,0 +1,1891 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway interceptor framework. +//! +//! The gateway integrates this crate once at the gRPC routing boundary. The +//! runtime uses the generated protobuf descriptor set to decode unary +//! `openshell.v1.OpenShell` request frames into protobuf-JSON-shaped values, +//! apply interceptor decisions, and re-encode the request before tonic reaches +//! the handler. Handler modules do not need per-method interceptor hooks. + +#![allow(clippy::result_large_err)] + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use hyper_util::rt::TokioIo; +use json_patch::{PatchOperation, patch}; +use metrics::{counter, histogram}; +use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayInterceptorPhaseConfig, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorResult, InterceptorSelector, JsonPatch, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost::Message as _; +use prost_types::{ + DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, + FileDescriptorSet, Struct, + field_descriptor_proto::{Label, Type}, +}; +use serde_json::{Map, Number, Value}; +use tokio::net::UnixStream; +use tonic::codegen::http::Uri; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Code, Request, Status}; +use tower::service_fn; +use tracing::{debug, info, warn}; + +pub mod routes; + +const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; +const DEFAULT_MAX_PATCHES: usize = 32; +const GRPC_HEADER_LEN: usize = 5; + +#[derive(Debug, thiserror::Error)] +pub enum InterceptorError { + #[error("invalid interceptor config: {0}")] + Config(String), + #[error("interceptor transport error: {0}")] + Transport(String), + #[error("invalid interceptor result: {0}")] + InvalidResult(String), + #[error("protobuf transcode error: {0}")] + Transcode(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + PreRequest, + ModifyOperation, + Validate, + PostCommit, +} + +impl Phase { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::PreRequest => "pre_request", + Self::ModifyOperation => "modify_operation", + Self::Validate => "validate", + Self::PostCommit => "post_commit", + } + } + + #[must_use] + pub const fn to_proto(self) -> GatewayInterceptorPhase { + match self { + Self::PreRequest => GatewayInterceptorPhase::PreRequest, + Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, + Self::Validate => GatewayInterceptorPhase::Validate, + Self::PostCommit => GatewayInterceptorPhase::PostCommit, + } + } +} + +impl TryFrom for Phase { + type Error = InterceptorError; + + fn try_from(value: GatewayInterceptorPhase) -> Result { + match value { + GatewayInterceptorPhase::PreRequest => Ok(Self::PreRequest), + GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), + GatewayInterceptorPhase::Validate => Ok(Self::Validate), + GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), + GatewayInterceptorPhase::Unspecified => Err(InterceptorError::Config( + "binding phase must not be unspecified".to_string(), + )), + } + } +} + +impl From for Phase { + fn from(value: GatewayInterceptorPhaseConfig) -> Self { + match value { + GatewayInterceptorPhaseConfig::PreRequest => Self::PreRequest, + GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, + GatewayInterceptorPhaseConfig::Validate => Self::Validate, + GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + FailClosed, + FailOpen, + Ignore, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + Self::Ignore => "ignore", + } + } +} + +impl From for FailurePolicy { + fn from(value: GatewayInterceptorFailurePolicy) -> Self { + match value { + GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, + GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, + GatewayInterceptorFailurePolicy::Ignore => Self::Ignore, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RpcSelector { + pub service: String, + pub method: String, +} + +impl RpcSelector { + #[must_use] + pub fn new(service: impl Into, method: impl Into) -> Self { + Self { + service: service.into(), + method: method.into(), + } + } + + #[must_use] + pub fn rpc(&self) -> String { + format!("{}/{}", self.service, self.method) + } + + #[must_use] + pub fn from_grpc_path(path: &str) -> Option { + let path = path.strip_prefix('/').unwrap_or(path); + let (service, method) = path.rsplit_once('/')?; + Some(Self::new(service, method)) + } +} + +#[derive(Clone)] +struct BindingPlan { + interceptor_name: String, + binding_id: String, + selector: RpcSelector, + phase: Phase, + failure_policy: FailurePolicy, + timeout: Duration, + max_response_bytes: usize, + max_patches: usize, + client: GatewayInterceptorClient, +} + +impl std::fmt::Debug for BindingPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BindingPlan") + .field("interceptor_name", &self.interceptor_name) + .field("binding_id", &self.binding_id) + .field("selector", &self.selector) + .field("phase", &self.phase) + .field("failure_policy", &self.failure_policy) + .field("timeout", &self.timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("max_patches", &self.max_patches) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub struct GatewayInterceptorRuntime { + bindings: Arc>>, + routes: Arc, + descriptors: Arc, +} + +#[derive(Debug, Clone)] +pub struct EvaluationContext { + pub principal: BTreeMap, + pub current_state: Option, +} + +#[derive(Debug, Clone)] +pub struct InterceptedRequest { + pub body: Vec, + selector: RpcSelector, + operation: Value, +} + +/// Return `None` when no interceptors are configured. +pub async fn initialize( + configs: Vec, +) -> Result> { + if configs.is_empty() { + return Ok(None); + } + let runtime = GatewayInterceptorRuntime::build(configs).await?; + Ok(Some(runtime)) +} + +impl GatewayInterceptorRuntime { + async fn build(mut configs: Vec) -> Result { + configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); + + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; + let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); + + for config in configs { + validate_service_config(&config)?; + let channel = connect_endpoint(&config.grpc_endpoint).await?; + let mut client = GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size( + config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), + ); + let manifest = client + .describe(Request::new(DescribeRequest {})) + .await + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); + let service_default = config + .failure_policy + .map(FailurePolicy::from) + .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) + .unwrap_or(FailurePolicy::FailClosed); + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; + let max_response_bytes = config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + let max_patches = config.max_patches.unwrap_or(DEFAULT_MAX_PATCHES); + + let override_index = OverrideIndex::new(&config.bindings)?; + for manifest_binding in &manifest.bindings { + let normalized = normalize_binding( + &config.name, + manifest_binding, + service_default, + &override_index, + )?; + let Some(normalized) = normalized else { + continue; + }; + if !routes + .is_interceptable(&normalized.selector.service, &normalized.selector.method) + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' targets non-interceptable RPC '{}'", + config.name, + normalized.binding_id, + normalized.selector.rpc() + ))); + } + for phase in normalized.phases { + if normalized.failure_policy == FailurePolicy::Ignore + && phase != Phase::PostCommit + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' uses failure_policy=ignore outside post_commit", + config.name, normalized.binding_id + ))); + } + let plan = BindingPlan { + interceptor_name: config.name.clone(), + binding_id: normalized.binding_id.clone(), + selector: normalized.selector.clone(), + phase, + failure_policy: normalized.failure_policy, + timeout, + max_response_bytes, + max_patches, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }; + bindings + .entry((normalized.selector.clone(), phase)) + .or_default() + .push(plan); + } + } + } + + let count: usize = bindings.values().map(Vec::len).sum(); + info!(bindings = count, "gateway interceptors initialized"); + Ok(Self { + bindings: Arc::new(bindings), + routes: Arc::new(routes), + descriptors: Arc::new(descriptors), + }) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.bindings.is_empty() + } + + #[must_use] + pub fn should_intercept_path(&self, path: &str) -> bool { + let Some(selector) = RpcSelector::from_grpc_path(path) else { + return false; + }; + self.routes + .is_interceptable(&selector.service, &selector.method) + && [ + Phase::PreRequest, + Phase::ModifyOperation, + Phase::Validate, + Phase::PostCommit, + ] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + } + + pub async fn evaluate_request( + &self, + path: &str, + body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result { + let selector = RpcSelector::from_grpc_path(path) + .ok_or_else(|| Status::invalid_argument("invalid gRPC method path"))?; + let input_type = self + .routes + .input_type(&selector.service, &selector.method) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? + .to_string(); + let frame = GrpcFrame::decode(body)?; + let mut operation = self + .descriptors + .decode_message_to_json(&input_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + operation = self + .evaluate_phase(&selector, Phase::PreRequest, operation, context) + .await?; + operation = self + .evaluate_phase(&selector, Phase::ModifyOperation, operation, context) + .await?; + operation = self + .evaluate_phase(&selector, Phase::Validate, operation, context) + .await?; + + let message = self + .descriptors + .encode_json_to_message(&input_type, &operation) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let body = GrpcFrame { + compressed: false, + message, + } + .encode() + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + Ok(InterceptedRequest { + body, + selector, + operation, + }) + } + + pub async fn evaluate_post_commit( + &self, + intercepted: &InterceptedRequest, + context: &EvaluationContext, + ) -> std::result::Result<(), Status> { + self.evaluate_phase( + &intercepted.selector, + Phase::PostCommit, + intercepted.operation.clone(), + context, + ) + .await + .map(|_| ()) + } + + async fn evaluate_phase( + &self, + selector: &RpcSelector, + phase: Phase, + operation: Value, + context: &EvaluationContext, + ) -> std::result::Result { + let Some(plans) = self.bindings.get(&(selector.clone(), phase)) else { + return Ok(operation); + }; + + let mut operation = operation; + for plan in plans { + let result = evaluate_plan(plan, operation.clone(), context).await; + let result = match result { + Ok(result) => result, + Err(err) => { + apply_failure_policy(plan, &err)?; + continue; + } + }; + + if let Err(err) = validate_result_contract(plan, &result) { + apply_failure_policy(plan, &err)?; + continue; + } + + if !result.allowed { + let reason = if result.reason.trim().is_empty() { + "operation denied by gateway interceptor".to_string() + } else { + result.reason.clone() + }; + emit_evaluation_metrics(plan, "deny", 0); + return Err(status_from_result(&result, reason)); + } + + if phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + let patch_ops = json_patch_operations(&result.patches) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + patch(&mut operation, &patch_ops).map_err(|err| { + Status::invalid_argument(format!("invalid JSON patch: {err}")) + })?; + emit_evaluation_metrics(plan, "allow", patch_count); + } else { + emit_evaluation_metrics(plan, "allow", 0); + } + } + Ok(operation) + } +} + +#[derive(Debug, Clone)] +struct NormalizedBinding { + binding_id: String, + selector: RpcSelector, + phases: Vec, + failure_policy: FailurePolicy, +} + +#[derive(Debug)] +struct OverrideIndex<'a> { + by_id: HashMap<&'a str, &'a GatewayInterceptorBindingOverride>, + by_selector: HashMap, +} + +impl<'a> OverrideIndex<'a> { + fn new(overrides: &'a [GatewayInterceptorBindingOverride]) -> Result { + let mut by_id = HashMap::new(); + let mut by_selector = HashMap::new(); + for override_cfg in overrides { + if let Some(id) = override_cfg.id.as_deref() + && by_id.insert(id, override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override id '{id}'" + ))); + } + if let Some(selector) = override_selector(override_cfg)? + && by_selector.insert(selector.rpc(), override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override selector '{}'", + selector.rpc() + ))); + } + } + Ok(Self { by_id, by_selector }) + } + + fn get( + &self, + binding_id: &str, + selector: &RpcSelector, + ) -> Option<&'a GatewayInterceptorBindingOverride> { + self.by_id + .get(binding_id) + .or_else(|| self.by_selector.get(&selector.rpc())) + .copied() + } +} + +fn validate_service_config(config: &GatewayInterceptorConfig) -> Result<()> { + if config.name.trim().is_empty() { + return Err(InterceptorError::Config( + "interceptor name must not be empty".to_string(), + )); + } + if config.grpc_endpoint.trim().is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{}' grpc_endpoint must not be empty", + config.name + ))); + } + if let Some(timeout) = config.timeout.as_deref() { + parse_duration(timeout)?; + } + Ok(()) +} + +fn normalize_binding( + interceptor_name: &str, + binding: &InterceptorBinding, + service_default: FailurePolicy, + overrides: &OverrideIndex<'_>, +) -> Result> { + let binding_id = binding.id.trim(); + if binding_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared a binding without id" + ))); + } + + let selector = selector_from_proto(binding.selector.as_ref())?; + let mut phases = binding + .phases + .iter() + .map(|phase| { + GatewayInterceptorPhase::try_from(*phase) + .map_err(|_| InterceptorError::Config("unknown binding phase".to_string())) + .and_then(Phase::try_from) + }) + .collect::>>()?; + phases.sort_unstable(); + phases.dedup(); + if phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' declares no phases" + ))); + } + + let mut failure_policy = + parse_failure_policy(binding.failure_policy.as_str()).unwrap_or(service_default); + + if let Some(override_cfg) = overrides.get(binding_id, &selector) { + if let Some(override_selector) = override_selector(override_cfg)? + && override_selector != selector + { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot widen selector '{}' to '{}'", + selector.rpc(), + override_selector.rpc() + ))); + } + if override_cfg.disabled { + return Ok(None); + } + if let Some(override_phases) = &override_cfg.phases { + let override_set: BTreeSet = + override_phases.iter().copied().map(Phase::from).collect(); + let declared: BTreeSet = phases.iter().copied().collect(); + if !override_set.is_subset(&declared) { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot add phases not declared by the manifest" + ))); + } + phases = override_set.into_iter().collect(); + } + if let Some(policy) = override_cfg.failure_policy { + failure_policy = policy.into(); + } + } + + Ok(Some(NormalizedBinding { + binding_id: binding_id.to_string(), + selector, + phases, + failure_policy, + })) +} + +fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { + let selector = selector + .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; + if !selector.rpc.trim().is_empty() { + return parse_rpc_selector(&selector.rpc); + } + if selector.service.trim().is_empty() || selector.method.trim().is_empty() { + return Err(InterceptorError::Config( + "binding selector requires rpc or service+method".to_string(), + )); + } + Ok(RpcSelector::new( + selector.service.trim(), + selector.method.trim(), + )) +} + +fn override_selector( + override_cfg: &GatewayInterceptorBindingOverride, +) -> Result> { + if let Some(rpc) = override_cfg.rpc.as_deref() + && !rpc.trim().is_empty() + { + return parse_rpc_selector(rpc).map(Some); + } + match ( + override_cfg + .service + .as_deref() + .filter(|v| !v.trim().is_empty()), + override_cfg + .method + .as_deref() + .filter(|v| !v.trim().is_empty()), + ) { + (Some(service), Some(method)) => Ok(Some(RpcSelector::new(service.trim(), method.trim()))), + (None, None) => Ok(None), + _ => Err(InterceptorError::Config( + "binding override selector requires both service and method".to_string(), + )), + } +} + +fn parse_rpc_selector(value: &str) -> Result { + let (service, method) = value.trim().split_once('/').ok_or_else(|| { + InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + )) + })?; + if service.is_empty() || method.is_empty() || method.contains('/') { + return Err(InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + ))); + } + Ok(RpcSelector::new(service, method)) +} + +fn parse_failure_policy(value: &str) -> Result { + match value.trim() { + "" => Err(InterceptorError::Config( + "failure_policy must not be empty".to_string(), + )), + "fail_closed" => Ok(FailurePolicy::FailClosed), + "fail_open" => Ok(FailurePolicy::FailOpen), + "ignore" => Ok(FailurePolicy::Ignore), + other => Err(InterceptorError::Config(format!( + "unsupported failure_policy '{other}'" + ))), + } +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(InterceptorError::Config( + "timeout must not be empty".to_string(), + )); + } + if let Some(ms) = value.strip_suffix("ms") { + let millis = ms + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_millis(millis)); + } + if let Some(seconds) = value.strip_suffix('s') { + let seconds = seconds + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_secs(seconds)); + } + Err(InterceptorError::Config(format!( + "invalid timeout '{value}'; expected suffix ms or s" + ))) +} + +async fn connect_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if let Some(path) = endpoint.strip_prefix("unix://") { + return connect_unix_endpoint(PathBuf::from(path)).await; + } + Endpoint::from_shared(endpoint.to_string()) + .map_err(|e| { + InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) + })? + .connect() + .await + .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) +} + +#[cfg(unix)] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + let display = path.display().to_string(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) +} + +#[cfg(not(unix))] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + Err(InterceptorError::Config(format!( + "unix interceptor endpoints are not supported on this platform: {}", + path.display() + ))) +} + +async fn evaluate_plan( + plan: &BindingPlan, + operation: Value, + context: &EvaluationContext, +) -> Result { + let operation = json_to_struct(operation)?; + let current_state = context + .current_state + .clone() + .map(json_to_struct) + .transpose()? + .unwrap_or_default(); + let request = InterceptorEvaluation { + interceptor_name: plan.interceptor_name.clone(), + binding_id: plan.binding_id.clone(), + service: plan.selector.service.clone(), + method: plan.selector.method.clone(), + phase: plan.phase.to_proto() as i32, + operation: Some(operation), + current_state: Some(current_state), + principal: context.principal.clone().into_iter().collect(), + }; + + let start = Instant::now(); + let result = tokio::time::timeout( + plan.timeout, + plan.client.clone().evaluate(Request::new(request)), + ) + .await + .map_err(|_| InterceptorError::Transport("evaluation timed out".to_string()))? + .map_err(|status| InterceptorError::Transport(status.to_string()))? + .into_inner(); + let encoded_len = result.encoded_len(); + histogram!("openshell_gateway_interceptor_latency_seconds") + .record(start.elapsed().as_secs_f64()); + if encoded_len > plan.max_response_bytes { + return Err(InterceptorError::InvalidResult(format!( + "interceptor response exceeded max_response_bytes ({} > {})", + encoded_len, plan.max_response_bytes + ))); + } + Ok(result) +} + +fn apply_failure_policy( + plan: &BindingPlan, + err: &InterceptorError, +) -> std::result::Result<(), Status> { + match plan.failure_policy { + FailurePolicy::FailClosed => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed closed" + ); + counter!("openshell_gateway_interceptor_fail_closed_total").increment(1); + Err(Status::permission_denied(format!( + "gateway interceptor '{}' failed closed: {err}", + plan.interceptor_name + ))) + } + FailurePolicy::FailOpen => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed open" + ); + counter!("openshell_gateway_interceptor_fail_open_total").increment(1); + Ok(()) + } + FailurePolicy::Ignore => { + debug!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failure ignored" + ); + Ok(()) + } + } +} + +fn validate_result_contract(plan: &BindingPlan, result: &InterceptorResult) -> Result<()> { + if result.patches.len() > plan.max_patches { + return Err(InterceptorError::InvalidResult(format!( + "interceptor returned too many patches ({} > {})", + result.patches.len(), + plan.max_patches + ))); + } + if plan.phase != Phase::ModifyOperation && !result.patches.is_empty() { + return Err(InterceptorError::InvalidResult(format!( + "patches are invalid during {}", + plan.phase.as_str() + ))); + } + if plan.phase == Phase::PostCommit && (!result.allowed || !result.patches.is_empty()) { + return Err(InterceptorError::InvalidResult( + "post_commit cannot deny or mutate operations".to_string(), + )); + } + Ok(()) +} + +fn status_from_result(result: &InterceptorResult, reason: String) -> Status { + let code = grpc_code_from_name(&result.status_code).unwrap_or(Code::PermissionDenied); + Status::new(code, reason) +} + +fn grpc_code_from_name(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "OK" => Some(Code::Ok), + "CANCELLED" => Some(Code::Cancelled), + "UNKNOWN" => Some(Code::Unknown), + "INVALID_ARGUMENT" => Some(Code::InvalidArgument), + "DEADLINE_EXCEEDED" => Some(Code::DeadlineExceeded), + "NOT_FOUND" => Some(Code::NotFound), + "ALREADY_EXISTS" => Some(Code::AlreadyExists), + "PERMISSION_DENIED" => Some(Code::PermissionDenied), + "RESOURCE_EXHAUSTED" => Some(Code::ResourceExhausted), + "FAILED_PRECONDITION" => Some(Code::FailedPrecondition), + "ABORTED" => Some(Code::Aborted), + "OUT_OF_RANGE" => Some(Code::OutOfRange), + "UNIMPLEMENTED" => Some(Code::Unimplemented), + "INTERNAL" => Some(Code::Internal), + "UNAVAILABLE" => Some(Code::Unavailable), + "DATA_LOSS" => Some(Code::DataLoss), + "UNAUTHENTICATED" => Some(Code::Unauthenticated), + _ => None, + } +} + +fn json_patch_operations(patches: &[JsonPatch]) -> Result> { + let mut raw = Vec::with_capacity(patches.len()); + for patch in patches { + let mut op = Map::new(); + op.insert("op".to_string(), Value::String(patch.op.clone())); + op.insert("path".to_string(), Value::String(patch.path.clone())); + if !patch.from.is_empty() { + op.insert("from".to_string(), Value::String(patch.from.clone())); + } + if let Some(value) = patch.value.as_ref() { + op.insert("value".to_string(), protobuf_value_to_json(value)); + } + raw.push(Value::Object(op)); + } + serde_json::from_value(Value::Array(raw)) + .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) +} + +fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { + counter!("openshell_gateway_interceptor_evaluations_total").increment(1); + if patch_count > 0 { + counter!("openshell_gateway_interceptor_patches_total").increment(patch_count as u64); + } +} + +#[derive(Debug, Clone)] +struct GrpcFrame { + compressed: bool, + message: Vec, +} + +impl GrpcFrame { + fn decode(body: &[u8]) -> std::result::Result { + if body.len() < GRPC_HEADER_LEN { + return Err(Status::invalid_argument("gRPC request frame is too short")); + } + let compressed = body[0] != 0; + if compressed { + return Err(Status::unimplemented( + "gateway interceptors do not support compressed gRPC requests", + )); + } + let len = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize; + if body.len() != GRPC_HEADER_LEN + len { + return Err(Status::invalid_argument( + "gRPC request must contain exactly one frame", + )); + } + Ok(Self { + compressed, + message: body[GRPC_HEADER_LEN..].to_vec(), + }) + } + + fn encode(&self) -> Result> { + if self.compressed { + return Err(InterceptorError::Transcode( + "compressed gRPC frames are not supported".to_string(), + )); + } + let len = u32::try_from(self.message.len()) + .map_err(|_| InterceptorError::Transcode("message exceeds u32".to_string()))?; + let mut out = Vec::with_capacity(GRPC_HEADER_LEN + self.message.len()); + out.push(0); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&self.message); + Ok(out) + } +} + +#[derive(Debug, Clone, Default)] +struct ProtoDescriptors { + messages: HashMap, + enums: HashMap, +} + +impl ProtoDescriptors { + fn from_descriptor_set(bytes: &[u8]) -> Result { + let set = FileDescriptorSet::decode(bytes) + .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + let mut descriptors = Self::default(); + for file in &set.file { + descriptors.add_file(file)?; + } + Ok(descriptors) + } + + fn add_file(&mut self, file: &FileDescriptorProto) -> Result<()> { + let package = file.package.as_deref().unwrap_or(""); + for message in &file.message_type { + self.add_message(package, None, message)?; + } + for enum_desc in &file.enum_type { + self.add_enum(package, None, enum_desc); + } + Ok(()) + } + + fn add_message( + &mut self, + package: &str, + parent: Option<&str>, + message: &DescriptorProto, + ) -> Result<()> { + let name = message.name.as_deref().unwrap_or(""); + let full_name = join_type_name(package, parent, name); + let map_entry = message + .options + .as_ref() + .is_some_and(prost_types::MessageOptions::map_entry); + let mut fields = BTreeMap::new(); + let mut fields_by_json = HashMap::new(); + for field in &message.field { + let field_desc = FieldDesc::from_proto(field)?; + fields_by_json.insert(field_desc.json_name.clone(), field_desc.number); + fields_by_json.insert(field_desc.name.clone(), field_desc.number); + fields.insert(field_desc.number, field_desc); + } + self.messages.insert( + full_name.clone(), + MessageDesc { + fields, + fields_by_json, + map_entry, + }, + ); + for nested in &message.nested_type { + self.add_message(package, Some(&full_name), nested)?; + } + for enum_desc in &message.enum_type { + self.add_enum(package, Some(&full_name), enum_desc); + } + Ok(()) + } + + fn add_enum(&mut self, package: &str, parent: Option<&str>, enum_desc: &EnumDescriptorProto) { + let name = enum_desc.name.as_deref().unwrap_or(""); + let full_name = join_type_name(package, parent, name); + let mut names_by_number = HashMap::new(); + let mut numbers_by_name = HashMap::new(); + for value in &enum_desc.value { + let Some(name) = value.name.as_ref() else { + continue; + }; + let number = value.number(); + names_by_number.insert(number, name.clone()); + numbers_by_name.insert(name.clone(), number); + } + self.enums.insert( + full_name, + EnumDesc { + names_by_number, + numbers_by_name, + }, + ); + } + + fn message(&self, name: &str) -> Result<&MessageDesc> { + self.messages + .get(trim_type_name(name)) + .ok_or_else(|| InterceptorError::Transcode(format!("unknown message type '{name}'"))) + } + + fn field_is_map(&self, field: &FieldDesc) -> bool { + field.repeated + && field.kind == FieldKind::Message + && field + .type_name + .as_ref() + .and_then(|name| self.messages.get(name)) + .is_some_and(|message| message.map_entry) + } + + fn decode_message_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + let message = self.message(type_name)?; + let mut values: HashMap> = HashMap::new(); + let mut input = bytes; + while !input.is_empty() { + let key = decode_varint(&mut input)?; + let field_number = u32::try_from(key >> 3) + .map_err(|_| InterceptorError::Transcode("field number overflow".to_string()))?; + let wire_type = u8::try_from(key & 0x07) + .map_err(|_| InterceptorError::Transcode("wire type overflow".to_string()))?; + let Some(field) = message.fields.get(&field_number) else { + skip_unknown(wire_type, &mut input)?; + continue; + }; + let decoded = self.decode_field_value(field, wire_type, &mut input)?; + values.entry(field_number).or_default().extend(decoded); + } + + let mut out = Map::new(); + for field in message.fields.values() { + let field_values = values.remove(&field.number).unwrap_or_default(); + if field_values.is_empty() && !field.repeated { + continue; + } + let value = if self.field_is_map(field) { + Self::map_values_to_json(field, field_values)? + } else if field.repeated { + Value::Array(field_values) + } else { + field_values.last().cloned().expect("empty values skipped") + }; + out.insert(field.json_name.clone(), value); + } + Ok(Value::Object(out)) + } + + fn decode_field_value( + &self, + field: &FieldDesc, + wire_type: u8, + input: &mut &[u8], + ) -> Result> { + if wire_type == 2 && field.repeated && field.is_packable() { + let bytes = decode_length_delimited(input)?; + let mut packed = bytes.as_slice(); + let mut values = Vec::new(); + while !packed.is_empty() { + values.push(self.decode_scalar_json( + field, + field.packed_wire_type(), + &mut packed, + )?); + } + return Ok(values); + } + Ok(vec![self.decode_scalar_json(field, wire_type, input)?]) + } + + fn decode_scalar_json( + &self, + field: &FieldDesc, + wire_type: u8, + input: &mut &[u8], + ) -> Result { + match field.kind { + FieldKind::Double => { + expect_wire(wire_type, 1)?; + Ok(number_json(f64::from_bits(decode_fixed64(input)?))) + } + FieldKind::Float => { + expect_wire(wire_type, 5)?; + Ok(number_json(f64::from(f32::from_bits(decode_fixed32( + input, + )?)))) + } + FieldKind::Int64 | FieldKind::Sfixed64 | FieldKind::Sint64 => { + let value = if field.kind == FieldKind::Sfixed64 { + expect_wire(wire_type, 1)?; + decode_fixed64(input)?.cast_signed() + } else if field.kind == FieldKind::Sint64 { + expect_wire(wire_type, 0)?; + decode_zigzag64(decode_varint(input)?) + } else { + expect_wire(wire_type, 0)?; + decode_varint(input)?.cast_signed() + }; + Ok(Value::String(value.to_string())) + } + FieldKind::Uint64 | FieldKind::Fixed64 => { + let value = if field.kind == FieldKind::Fixed64 { + expect_wire(wire_type, 1)?; + decode_fixed64(input)? + } else { + expect_wire(wire_type, 0)?; + decode_varint(input)? + }; + Ok(Value::String(value.to_string())) + } + FieldKind::Int32 | FieldKind::Sint32 | FieldKind::Sfixed32 => { + let value = if field.kind == FieldKind::Sfixed32 { + expect_wire(wire_type, 5)?; + decode_fixed32(input)?.cast_signed() + } else if field.kind == FieldKind::Sint32 { + expect_wire(wire_type, 0)?; + let raw = u32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds sint32", field.name)) + })?; + decode_zigzag32(raw) + } else { + expect_wire(wire_type, 0)?; + i32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds int32", field.name)) + })? + }; + Ok(Value::Number(Number::from(value))) + } + FieldKind::Uint32 | FieldKind::Fixed32 => { + let value = if field.kind == FieldKind::Fixed32 { + expect_wire(wire_type, 5)?; + decode_fixed32(input)? + } else { + expect_wire(wire_type, 0)?; + u32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds u32", field.name)) + })? + }; + Ok(Value::Number(Number::from(value))) + } + FieldKind::Bool => { + expect_wire(wire_type, 0)?; + Ok(Value::Bool(decode_varint(input)? != 0)) + } + FieldKind::String => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + String::from_utf8(bytes) + .map(Value::String) + .map_err(|e| InterceptorError::Transcode(format!("invalid UTF-8: {e}"))) + } + FieldKind::Bytes => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + Ok(Value::String( + base64::engine::general_purpose::STANDARD.encode(bytes), + )) + } + FieldKind::Enum => { + expect_wire(wire_type, 0)?; + let number = i32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds enum int32", field.name)) + })?; + if let Some(enum_type) = field + .type_name + .as_ref() + .and_then(|name| self.enums.get(name)) + && let Some(name) = enum_type.names_by_number.get(&number) + { + return Ok(Value::String(name.clone())); + } + Ok(Value::Number(Number::from(number))) + } + FieldKind::Message => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "message field {} lacks type_name", + field.name + )) + })?; + self.decode_message_to_json(type_name, &bytes) + } + } + } + + fn map_values_to_json(_field: &FieldDesc, values: Vec) -> Result { + let mut map = Map::new(); + for value in values { + let Value::Object(mut entry) = value else { + return Err(InterceptorError::Transcode( + "map entry was not object".to_string(), + )); + }; + let key = entry + .remove("key") + .ok_or_else(|| InterceptorError::Transcode("map entry missing key".to_string()))?; + let key = match key { + Value::String(value) => value, + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + other => { + return Err(InterceptorError::Transcode(format!( + "unsupported map key value {other:?}" + ))); + } + }; + let value = entry.remove("value").unwrap_or(Value::Null); + map.insert(key, value); + } + Ok(Value::Object(map)) + } + + fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + let message = self.message(type_name)?; + let Value::Object(map) = value else { + return Err(InterceptorError::Transcode(format!( + "{type_name} JSON must be an object" + ))); + }; + let mut out = Vec::new(); + for (json_name, value) in map { + if value.is_null() { + continue; + } + let Some(number) = message.fields_by_json.get(json_name) else { + return Err(InterceptorError::Transcode(format!( + "unknown field '{json_name}' on {type_name}" + ))); + }; + let field = message.fields.get(number).expect("field index is valid"); + if self.field_is_map(field) { + self.encode_map_field(field, value, &mut out)?; + } else if field.repeated { + let Value::Array(values) = value else { + return Err(InterceptorError::Transcode(format!( + "repeated field '{}' must be an array", + field.json_name + ))); + }; + for item in values { + self.encode_field(field, item, &mut out)?; + } + } else { + self.encode_field(field, value, &mut out)?; + } + } + Ok(out) + } + + fn encode_map_field(&self, field: &FieldDesc, value: &Value, out: &mut Vec) -> Result<()> { + let Value::Object(map) = value else { + return Err(InterceptorError::Transcode(format!( + "map field '{}' must be an object", + field.json_name + ))); + }; + let entry_type = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!("map field '{}' lacks entry type", field.name)) + })?; + for (key, value) in map { + let entry = Value::Object(Map::from_iter([ + ("key".to_string(), Value::String(key.clone())), + ("value".to_string(), value.clone()), + ])); + let encoded = self.encode_json_to_message(entry_type, &entry)?; + encode_key(field.number, 2, out); + encode_length_delimited(&encoded, out)?; + } + Ok(()) + } + + fn encode_field(&self, field: &FieldDesc, value: &Value, out: &mut Vec) -> Result<()> { + match field.kind { + FieldKind::Double => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_f64(value, &field.json_name)?.to_bits().to_le_bytes()); + } + FieldKind::Float => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_f32(value, &field.json_name)?.to_bits().to_le_bytes()); + } + FieldKind::Int64 => { + encode_key(field.number, 0, out); + encode_varint(json_i64(value, &field.json_name)?.cast_unsigned(), out); + } + FieldKind::Uint64 => { + encode_key(field.number, 0, out); + encode_varint(json_u64(value, &field.json_name)?, out); + } + FieldKind::Int32 => { + encode_key(field.number, 0, out); + encode_varint( + u64::from(json_i32(value, &field.json_name)?.cast_unsigned()), + out, + ); + } + FieldKind::Fixed64 => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_u64(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Fixed32 => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_u32(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Bool => { + encode_key(field.number, 0, out); + encode_varint(u64::from(json_bool(value, &field.json_name)?), out); + } + FieldKind::String => { + encode_key(field.number, 2, out); + let value = json_string(value, &field.json_name)?; + encode_length_delimited(value.as_bytes(), out)?; + } + FieldKind::Bytes => { + encode_key(field.number, 2, out); + let decoded = base64::engine::general_purpose::STANDARD + .decode(json_string(value, &field.json_name)?) + .map_err(|e| { + InterceptorError::Transcode(format!("invalid base64 bytes: {e}")) + })?; + encode_length_delimited(&decoded, out)?; + } + FieldKind::Uint32 => { + encode_key(field.number, 0, out); + encode_varint(u64::from(json_u32(value, &field.json_name)?), out); + } + FieldKind::Enum => { + encode_key(field.number, 0, out); + let number = self.json_enum_number(field, value)?; + encode_varint(u64::from(number.cast_unsigned()), out); + } + FieldKind::Sfixed32 => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_i32(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Sfixed64 => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_i64(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Sint32 => { + encode_key(field.number, 0, out); + encode_varint( + u64::from(encode_zigzag32(json_i32(value, &field.json_name)?)), + out, + ); + } + FieldKind::Sint64 => { + encode_key(field.number, 0, out); + encode_varint(encode_zigzag64(json_i64(value, &field.json_name)?), out); + } + FieldKind::Message => { + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "message field {} lacks type_name", + field.name + )) + })?; + let encoded = self.encode_json_to_message(type_name, value)?; + encode_key(field.number, 2, out); + encode_length_delimited(&encoded, out)?; + } + } + Ok(()) + } + + fn json_enum_number(&self, field: &FieldDesc, value: &Value) -> Result { + match value { + Value::String(name) => { + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "enum field {} lacks type_name", + field.name + )) + })?; + self.enums + .get(type_name) + .and_then(|desc| desc.numbers_by_name.get(name)) + .copied() + .ok_or_else(|| { + InterceptorError::Transcode(format!( + "unknown enum value '{name}' for {}", + field.json_name + )) + }) + } + Value::Number(number) => number + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + .ok_or_else(|| { + InterceptorError::Transcode(format!("{} must be enum", field.json_name)) + }), + _ => Err(InterceptorError::Transcode(format!( + "{} must be enum string or number", + field.json_name + ))), + } + } +} + +#[derive(Debug, Clone, Default)] +struct MessageDesc { + fields: BTreeMap, + fields_by_json: HashMap, + map_entry: bool, +} + +#[derive(Debug, Clone)] +struct FieldDesc { + name: String, + json_name: String, + number: u32, + repeated: bool, + kind: FieldKind, + type_name: Option, +} + +impl FieldDesc { + fn from_proto(field: &FieldDescriptorProto) -> Result { + let name = field.name.clone().unwrap_or_default(); + let json_name = field + .json_name + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| snake_to_lower_camel(&name)); + let number = u32::try_from(field.number()) + .map_err(|_| InterceptorError::Config(format!("field '{name}' has invalid number")))?; + let repeated = field.label() == Label::Repeated; + let kind = FieldKind::from_type(field.r#type())?; + let type_name = field + .type_name + .as_ref() + .map(|name| trim_type_name(name).to_string()); + Ok(Self { + name, + json_name, + number, + repeated, + kind, + type_name, + }) + } + + fn is_packable(&self) -> bool { + !matches!( + self.kind, + FieldKind::String | FieldKind::Bytes | FieldKind::Message + ) + } + + fn packed_wire_type(&self) -> u8 { + match self.kind { + FieldKind::Double | FieldKind::Fixed64 | FieldKind::Sfixed64 => 1, + FieldKind::Float | FieldKind::Fixed32 | FieldKind::Sfixed32 => 5, + _ => 0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldKind { + Double, + Float, + Int64, + Uint64, + Int32, + Fixed64, + Fixed32, + Bool, + String, + Message, + Bytes, + Uint32, + Enum, + Sfixed32, + Sfixed64, + Sint32, + Sint64, +} + +impl FieldKind { + fn from_type(value: Type) -> Result { + match value { + Type::Double => Ok(Self::Double), + Type::Float => Ok(Self::Float), + Type::Int64 => Ok(Self::Int64), + Type::Uint64 => Ok(Self::Uint64), + Type::Int32 => Ok(Self::Int32), + Type::Fixed64 => Ok(Self::Fixed64), + Type::Fixed32 => Ok(Self::Fixed32), + Type::Bool => Ok(Self::Bool), + Type::String => Ok(Self::String), + Type::Group => Err(InterceptorError::Transcode( + "protobuf groups are not supported".to_string(), + )), + Type::Message => Ok(Self::Message), + Type::Bytes => Ok(Self::Bytes), + Type::Uint32 => Ok(Self::Uint32), + Type::Enum => Ok(Self::Enum), + Type::Sfixed32 => Ok(Self::Sfixed32), + Type::Sfixed64 => Ok(Self::Sfixed64), + Type::Sint32 => Ok(Self::Sint32), + Type::Sint64 => Ok(Self::Sint64), + } + } +} + +#[derive(Debug, Clone, Default)] +struct EnumDesc { + names_by_number: HashMap, + numbers_by_name: HashMap, +} + +fn join_type_name(package: &str, parent: Option<&str>, name: &str) -> String { + parent.map_or_else( + || { + if package.is_empty() { + name.to_string() + } else { + format!("{package}.{name}") + } + }, + |parent| format!("{parent}.{name}"), + ) +} + +fn trim_type_name(name: &str) -> &str { + name.strip_prefix('.').unwrap_or(name) +} + +fn snake_to_lower_camel(value: &str) -> String { + let mut out = String::new(); + let mut uppercase = false; + for ch in value.chars() { + if ch == '_' { + uppercase = true; + } else if uppercase { + out.extend(ch.to_uppercase()); + uppercase = false; + } else { + out.push(ch); + } + } + out +} + +fn json_to_struct(value: Value) -> Result { + match value { + Value::Object(fields) => Ok(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "operation JSON must be an object".to_string(), + )), + } +} + +fn json_to_protobuf_value(value: Value) -> Result { + let kind = match value { + Value::Null => prost_types::value::Kind::NullValue(0), + Value::Bool(value) => prost_types::value::Kind::BoolValue(value), + Value::Number(value) => prost_types::value::Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode("invalid JSON number".to_string()))?, + ), + Value::String(value) => prost_types::value::Kind::StringValue(value), + Value::Array(values) => prost_types::value::Kind::ListValue(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + Value::Object(fields) => prost_types::value::Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + }; + Ok(prost_types::Value { kind: Some(kind) }) +} + +fn protobuf_value_to_json(value: &prost_types::Value) -> Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, + Some(prost_types::value::Kind::NumberValue(value)) => number_json(*value), + Some(prost_types::value::Kind::StringValue(value)) => Value::String(value.clone()), + Some(prost_types::value::Kind::BoolValue(value)) => Value::Bool(*value), + Some(prost_types::value::Kind::StructValue(value)) => Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) + .collect(), + ), + Some(prost_types::value::Kind::ListValue(value)) => { + Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) + } + } +} + +fn number_json(value: f64) -> Value { + Number::from_f64(value).map_or(Value::Null, Value::Number) +} + +fn expect_wire(actual: u8, expected: u8) -> Result<()> { + if actual == expected { + Ok(()) + } else { + Err(InterceptorError::Transcode(format!( + "wire type mismatch: got {actual}, expected {expected}" + ))) + } +} + +fn decode_varint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let Some((&byte, rest)) = input.split_first() else { + return Err(InterceptorError::Transcode("truncated varint".to_string())); + }; + *input = rest; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err(InterceptorError::Transcode("varint overflow".to_string())) +} + +fn decode_fixed32(input: &mut &[u8]) -> Result { + if input.len() < 4 { + return Err(InterceptorError::Transcode("truncated fixed32".to_string())); + } + let (bytes, rest) = input.split_at(4); + *input = rest; + Ok(u32::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_fixed64(input: &mut &[u8]) -> Result { + if input.len() < 8 { + return Err(InterceptorError::Transcode("truncated fixed64".to_string())); + } + let (bytes, rest) = input.split_at(8); + *input = rest; + Ok(u64::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_length_delimited(input: &mut &[u8]) -> Result> { + let len = usize::try_from(decode_varint(input)?) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?; + if input.len() < len { + return Err(InterceptorError::Transcode( + "truncated length-delimited field".to_string(), + )); + } + let (bytes, rest) = input.split_at(len); + *input = rest; + Ok(bytes.to_vec()) +} + +fn skip_unknown(wire_type: u8, input: &mut &[u8]) -> Result<()> { + match wire_type { + 0 => { + decode_varint(input)?; + } + 1 => { + decode_fixed64(input)?; + } + 2 => { + decode_length_delimited(input)?; + } + 5 => { + decode_fixed32(input)?; + } + other => { + return Err(InterceptorError::Transcode(format!( + "unsupported unknown wire type {other}" + ))); + } + } + Ok(()) +} + +fn decode_zigzag32(value: u32) -> i32 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn decode_zigzag64(value: u64) -> i64 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn encode_zigzag32(value: i32) -> u32 { + ((value << 1) ^ (value >> 31)).cast_unsigned() +} + +fn encode_zigzag64(value: i64) -> u64 { + ((value << 1) ^ (value >> 63)).cast_unsigned() +} + +fn encode_key(field_number: u32, wire_type: u8, out: &mut Vec) { + encode_varint((u64::from(field_number) << 3) | u64::from(wire_type), out); +} + +fn encode_varint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + let byte = u8::try_from(value & 0x7f).expect("masked varint byte fits u8"); + out.push(byte | 0x80); + value >>= 7; + } + out.push(u8::try_from(value).expect("final varint byte fits u8")); +} + +fn encode_length_delimited(bytes: &[u8], out: &mut Vec) -> Result<()> { + encode_varint( + u64::try_from(bytes.len()) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?, + out, + ); + out.extend_from_slice(bytes); + Ok(()) +} + +fn json_string<'a>(value: &'a Value, field: &str) -> Result<&'a str> { + value + .as_str() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a string"))) +} + +fn json_bool(value: &Value, field: &str) -> Result { + value + .as_bool() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a bool"))) +} + +fn json_f64(value: &Value, field: &str) -> Result { + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a number"))) +} + +#[allow(clippy::cast_possible_truncation)] +fn json_f32(value: &Value, field: &str) -> Result { + let value = json_f64(value, field)?; + if value.is_finite() && value >= f64::from(f32::MIN) && value <= f64::from(f32::MAX) { + Ok(value as f32) + } else { + Err(InterceptorError::Transcode(format!( + "{field} must be a finite float" + ))) + } +} + +fn json_i64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be int64 string"))), + Value::Number(value) => value + .as_i64() + .or_else(|| integral_f64(value).and_then(|value| i64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be int64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be int64" + ))), + } +} + +fn json_u64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be uint64 string"))), + Value::Number(value) => value + .as_u64() + .or_else(|| integral_f64(value).and_then(|value| u64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be uint64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be uint64" + ))), + } +} + +fn integral_f64(value: &Number) -> Option { + let value = value.as_f64()?; + if value.fract() == 0.0 && value.is_finite() { + format!("{value:.0}").parse().ok() + } else { + None + } +} + +fn json_i32(value: &Value, field: &str) -> Result { + i32::try_from(json_i64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds int32"))) +} + +fn json_u32(value: &Value, field: &str) -> Result { + u32::try_from(json_u64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds uint32"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{CreateSandboxRequest, SandboxSpec}; + + #[test] + fn parses_timeout_suffixes() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert!(parse_duration("2").is_err()); + } + + #[test] + fn dynamic_create_sandbox_round_trip_uses_json_names() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::from([("team".to_string(), "agent".to_string())]), + }; + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + assert_eq!(json["spec"]["providers"][0], "github"); + assert_eq!(json["labels"]["team"], "agent"); + let encoded = descriptors + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } +} diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs new file mode 100644 index 000000000..6ddaa08a9 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptable `OpenShell` route classification. + +use std::collections::{BTreeMap, BTreeSet}; + +use prost::Message as _; +use prost_types::FileDescriptorSet; + +use crate::{InterceptorError, Result}; + +const SERVICE_OPEN_SHELL: &str = "openshell.v1.OpenShell"; + +/// Unary `openshell.v1.OpenShell` methods that are deliberately excluded from +/// gateway interception. New unary methods are interceptable by default unless +/// added here in the same change. +pub const NON_INTERCEPTABLE_METHODS: &[&str] = &[ + "Health", + "WatchSandbox", + "ExecSandbox", + "ForwardTcp", + "ExecSandboxInteractive", + "PushSandboxLogs", + "ConnectSupervisor", + "RelayStream", + "GetSandboxConfig", + "GetSandboxProviderEnvironment", + "ReportPolicyStatus", + "SubmitPolicyAnalysis", + "IssueSandboxToken", + "RefreshSandboxToken", + "GetSandbox", + "ListSandboxes", + "ListSandboxProviders", + "GetProvider", + "ListProviders", + "ListProviderProfiles", + "GetProviderProfile", + "LintProviderProfiles", + "GetProviderRefreshStatus", + "GetGatewayConfig", + "GetSandboxPolicyStatus", + "ListSandboxPolicies", + "GetSandboxLogs", + "GetDraftPolicy", + "GetDraftHistory", + "GetService", + "ListServices", +]; + +#[derive(Debug, Clone)] +pub struct OpenShellRouteIndex { + all_methods: BTreeSet, + unary_methods: BTreeSet, + input_types: BTreeMap, +} + +impl OpenShellRouteIndex { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + let set = FileDescriptorSet::decode(bytes) + .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + let mut all_methods = BTreeSet::new(); + let mut unary_methods = BTreeSet::new(); + let mut input_types = BTreeMap::new(); + + for file in &set.file { + if file.package.as_deref() != Some("openshell.v1") { + continue; + } + for service in &file.service { + if service.name.as_deref() != Some("OpenShell") { + continue; + } + for method in &service.method { + let name = method.name.clone().unwrap_or_default(); + all_methods.insert(name.clone()); + if !method.client_streaming.unwrap_or(false) + && !method.server_streaming.unwrap_or(false) + { + let input_type = method + .input_type + .as_deref() + .unwrap_or_default() + .strip_prefix('.') + .unwrap_or_else(|| method.input_type.as_deref().unwrap_or_default()) + .to_string(); + unary_methods.insert(name.clone()); + input_types.insert(name, input_type); + } + } + } + } + + let index = Self { + all_methods, + unary_methods, + input_types, + }; + index.validate_non_interceptable_list()?; + Ok(index) + } + + #[must_use] + pub fn is_interceptable(&self, service: &str, method: &str) -> bool { + service == SERVICE_OPEN_SHELL + && self.unary_methods.contains(method) + && !NON_INTERCEPTABLE_METHODS.contains(&method) + } + + #[must_use] + pub fn input_type(&self, service: &str, method: &str) -> Option<&str> { + if service == SERVICE_OPEN_SHELL && self.unary_methods.contains(method) { + self.input_types.get(method).map(String::as_str) + } else { + None + } + } + + fn validate_non_interceptable_list(&self) -> Result<()> { + let mut stale = Vec::new(); + for method in NON_INTERCEPTABLE_METHODS { + if !self.all_methods.contains(*method) { + stale.push((*method).to_string()); + } + } + if !stale.is_empty() { + return Err(InterceptorError::Config(format!( + "non-interceptable route list has stale methods: {stale:?}" + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_interceptable_entries_match_real_methods() { + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + } + + #[test] + fn write_methods_are_interceptable_by_default() { + let index = + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + assert!(index.is_interceptable("openshell.v1.OpenShell", "CreateSandbox")); + assert!(index.is_interceptable("openshell.v1.OpenShell", "UpdateConfig")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "GetSandbox")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "WatchSandbox")); + } +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 39a26b14e..2b2840a29 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -20,6 +20,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-driver-docker = { path = "../openshell-driver-docker" } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } +openshell-gateway-interceptors = { path = "../openshell-gateway-interceptors" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index ce7734262..f3f4ec1f1 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -365,6 +365,11 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, ) + .with_gateway_interceptors( + file.as_ref() + .map(|f| f.openshell.gateway.interceptors.clone()) + .unwrap_or_default(), + ) .with_server_sans(args.server_sans.clone()) .with_loopback_service_http(args.enable_loopback_service_http); validate_grpc_rate_limit_args( diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 39cf02bba..3676dca87 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -25,7 +25,10 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use openshell_core::config::ComputeDriverKind; -use openshell_core::{GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig}; +use openshell_core::{ + GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, + TlsConfig, +}; use serde::{Deserialize, Serialize}; /// Latest schema version this build understands. @@ -147,6 +150,8 @@ pub struct GatewayFileSection { #[serde(default)] pub auth: Option, #[serde(default)] + pub interceptors: Vec, + #[serde(default)] pub mtls_auth: Option, #[serde(default)] pub gateway_jwt: Option, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index dda8708e0..3dee3baa0 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -137,6 +137,10 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + + /// Immutable gateway interceptor execution plan. `None` when disabled. + pub(crate) gateway_interceptors: + Option, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -187,6 +191,7 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + gateway_interceptors: None, } } } @@ -245,6 +250,12 @@ pub async fn run_server( supervisor_sessions.clone(), ) .await?; + let gateway_interceptors = + openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()) + .await + .map_err(|e| { + Error::config(format!("gateway interceptor initialization failed: {e}")) + })?; let mut state = ServerState::new( config.clone(), store.clone(), @@ -255,6 +266,7 @@ pub async fn run_server( supervisor_sessions, oidc_cache, ); + state.gateway_interceptors = gateway_interceptors; // Load the gateway-minted sandbox JWT signing key when configured. // Optional so single-driver dev deployments without certgen continue diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index f4faa0867..0fbdc04c0 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -7,9 +7,9 @@ //! to either the gRPC service or HTTP endpoints based on the request headers. use bytes::Bytes; -use http::{HeaderValue, Request, Response}; +use http::{Extensions, HeaderValue, Request, Response}; use http_body::Body; -use http_body_util::BodyExt; +use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, @@ -21,6 +21,9 @@ use openshell_core::Config; use openshell_core::proto::{ inference_server::InferenceServer, open_shell_server::OpenShellServer, }; +use openshell_gateway_interceptors::{EvaluationContext, GatewayInterceptorRuntime}; +use std::collections::BTreeMap; +use std::convert::Infallible; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -154,6 +157,8 @@ impl MultiplexService { { let openshell = OpenShellServer::new(OpenShellService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); + let openshell = + GatewayInterceptorGrpcService::new(openshell, self.state.gateway_interceptors.clone()); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { @@ -214,6 +219,164 @@ impl MultiplexService { } } +/// `OpenShell` gRPC wrapper that applies configured gateway interceptors before +/// tonic dispatches to a specific RPC handler. +#[derive(Clone)] +struct GatewayInterceptorGrpcService { + inner: S, + interceptors: Option, +} + +impl GatewayInterceptorGrpcService { + fn new(inner: S, interceptors: Option) -> Self { + Self { + inner, + interceptors, + } + } +} + +impl tower::Service> for GatewayInterceptorGrpcService +where + S: tower::Service, Response = Response> + + Clone + + Send + + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let interceptors = self.interceptors.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + let Some(interceptors) = interceptors else { + return inner.ready().await?.call(req).await; + }; + + let path = req.uri().path().to_string(); + if !interceptors.should_intercept_path(&path) { + return inner.ready().await?.call(req).await; + } + + let context = gateway_interceptor_context(req.extensions()); + let (parts, body) = req.into_parts(); + let body = match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(err) => { + return Ok(tonic::Status::internal(format!( + "failed to read gRPC request body for interceptor evaluation: {err}" + )) + .into_http()); + } + }; + + let intercepted = match interceptors.evaluate_request(&path, &body, &context).await { + Ok(intercepted) => intercepted, + Err(status) => return Ok(status.into_http()), + }; + + let req = Request::from_parts( + parts, + boxed_body_from_bytes(Bytes::from(intercepted.body.clone())), + ); + let response = inner.ready().await?.call(req).await?; + + if grpc_status_from_response(&response) == "0" + && let Err(status) = interceptors + .evaluate_post_commit(&intercepted, &context) + .await + { + return Ok(status.into_http()); + } + + Ok(response) + }) + } +} + +fn boxed_body_from_bytes(bytes: Bytes) -> BoxBody { + let body = Full::new(bytes) + .map_err(|never: Infallible| -> Box { match never {} }) + .boxed_unsync(); + BoxBody(body) +} + +fn gateway_interceptor_context(extensions: &Extensions) -> EvaluationContext { + EvaluationContext { + principal: extensions + .get::() + .map_or_else(unknown_gateway_principal, gateway_principal_fields), + current_state: None, + } +} + +fn gateway_principal_fields(principal: &Principal) -> BTreeMap { + use crate::auth::principal::SandboxIdentitySource; + + let mut fields = BTreeMap::new(); + match principal { + Principal::User(user) => { + fields.insert("kind".to_string(), "user".to_string()); + fields.insert("subject".to_string(), user.identity.subject.clone()); + if let Some(display_name) = &user.identity.display_name { + fields.insert("display_name".to_string(), display_name.clone()); + } + fields.insert( + "provider".to_string(), + identity_provider_name(user.identity.provider).to_string(), + ); + if !user.identity.roles.is_empty() { + fields.insert("roles".to_string(), user.identity.roles.join(",")); + } + if !user.identity.scopes.is_empty() { + fields.insert("scopes".to_string(), user.identity.scopes.join(",")); + } + } + Principal::Sandbox(sandbox) => { + fields.insert("kind".to_string(), "sandbox".to_string()); + fields.insert("sandbox_id".to_string(), sandbox.sandbox_id.clone()); + fields.insert( + "source".to_string(), + match &sandbox.source { + SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", + SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", + SandboxIdentitySource::K8sServiceAccount { .. } => "k8s_service_account", + } + .to_string(), + ); + if let Some(trust_domain) = &sandbox.trust_domain { + fields.insert("trust_domain".to_string(), trust_domain.clone()); + } + } + Principal::Anonymous => { + fields.insert("kind".to_string(), "anonymous".to_string()); + } + } + fields +} + +fn unknown_gateway_principal() -> BTreeMap { + BTreeMap::from([("kind".to_string(), "unknown".to_string())]) +} + +fn identity_provider_name(provider: crate::auth::identity::IdentityProvider) -> &'static str { + match provider { + crate::auth::identity::IdentityProvider::Oidc => "oidc", + crate::auth::identity::IdentityProvider::Mtls => "mtls", + crate::auth::identity::IdentityProvider::CloudflareAccess => "cloudflare_access", + crate::auth::identity::IdentityProvider::LocalDev => "local_dev", + } +} + #[derive(Clone, Debug)] pub struct GrpcRateLimiter { requests: u64, @@ -954,7 +1117,7 @@ mod tests { impl Service> for CountingGrpcService { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = std::future::Ready>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -986,7 +1149,7 @@ mod tests { impl Service> for PendingInnerService { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = std::future::Ready>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -1367,7 +1530,7 @@ mod tests { impl Service> for PrincipalRecorder { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = Pin> + Send>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ff4542136..414574701 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -132,6 +132,24 @@ roles_claim = "realm_access.roles" admin_role = "openshell-admin" user_role = "openshell-user" scopes_claim = "" + +[[openshell.gateway.interceptors]] +name = "quota" +grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" +order = 10 +failure_policy = "fail_closed" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +id = "quota-create-sandbox" +phases = ["pre_request", "validate"] +failure_policy = "fail_closed" + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +disabled = true ``` Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to authenticate CLI callers from verified client certificates. Kubernetes deployments must leave this unset and use OIDC or a trusted access proxy; the Helm chart does not render this table. @@ -140,6 +158,10 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +`[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target unary OpenShell methods that are not on the built-in supervisor, streaming, read-only, or introspection allowlist. Request bodies are exposed as protobuf JSON objects, so adding a new unary RPC does not require handler-specific interceptor code. + +`failure_policy` accepts `fail_closed`, `fail_open`, or `ignore`. Use `ignore` only for `post_commit` bindings. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. + `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. ## Driver References diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock new file mode 100644 index 000000000..6a8a15820 --- /dev/null +++ b/examples/governance-interceptor/Cargo.lock @@ -0,0 +1,1880 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "base64", + "ipnet", + "miette", + "prost", + "prost-types", + "protobuf-src", + "serde", + "serde_json", + "thiserror", + "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +dependencies = [ + "base64", + "openshell-core", + "openshell-policy", + "prost-types", + "serde_json", + "sha2", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf-src" +version = "1.1.0+21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" +dependencies = [ + "autotools", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +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 = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml new file mode 100644 index 000000000..ada1f5ecf --- /dev/null +++ b/examples/governance-interceptor/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" + +[dependencies] +base64 = "0.22" +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-policy = { path = "../../crates/openshell-policy" } +prost-types = "0.14" +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } +tonic = { version = "0.14", features = ["transport"] } + +[[bin]] +name = "governance-interceptor" +path = "src/main.rs" diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md new file mode 100644 index 000000000..c2f93a02e --- /dev/null +++ b/examples/governance-interceptor/README.md @@ -0,0 +1,64 @@ +# Governance Interceptor Example + +This standalone example implements the `openshell.gateway_interceptor.v1.GatewayInterceptor` service. It enforces a source-control governance baseline: + +- every new sandbox receives `policy.yaml` +- every new sandbox is attached to exactly `github` and `gitlab` +- every new sandbox gets an `openshell.nvidia.com/policy-signature` label +- users cannot attach or detach other providers after sandbox creation +- users cannot replace or merge sandbox policy after sandbox creation +- users cannot create provider records other than `github` and `gitlab` +- users cannot update or delete the governed `github` or `gitlab` provider records + +Run the interceptor: + +```shell +cargo run --manifest-path examples/governance-interceptor/Cargo.toml -- \ + --listen 127.0.0.1:18081 \ + --policy examples/governance-interceptor/policy.yaml +``` + +Gateway TOML snippet: + +```toml +[[openshell.gateway.interceptors]] +name = "source-control-governance" +grpc_endpoint = "http://127.0.0.1:18081" +order = 10 +failure_policy = "fail_closed" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 +``` + +Run the smoke test against a local gateway and compute driver: + +```shell +examples/governance-interceptor/smoke.sh +``` + +The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. + +Set `OPENSHELL_GOVERNANCE_SMOKE_DRIVER=docker|podman|vm|kubernetes` to force a driver. Without it, the gateway uses its existing local driver detection. + +On macOS the smoke script uses `clang`/`clang++` for native dependencies, because Apple SDK headers require Clang block syntax. It also disables `RUSTC_WRAPPER` by default so local `sccache` configuration does not affect the smoke run. + +The workspace build requires Z3. The smoke script uses `pkg-config`, `brew --prefix z3`, `/opt/homebrew/opt/z3`, or `/usr/local/opt/z3` when those locations contain `include/z3.h` and a `lib` directory. If no usable local Z3 install exists, install it first: + +```shell +brew install z3 +``` + +Build overrides: + +```shell +OPENSHELL_GOVERNANCE_CC=/path/to/clang \ +OPENSHELL_GOVERNANCE_CXX=/path/to/clang++ \ +OPENSHELL_GOVERNANCE_RUSTC_WRAPPER=sccache \ +Z3_SYS_Z3_HEADER=/path/to/include/z3.h \ +Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib \ +examples/governance-interceptor/smoke.sh +``` + +Set `OPENSHELL_GOVERNANCE_KEEP_CC=1` or `OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER=1` to preserve the caller environment. +Set `OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1` to opt into the bundled Z3 build, which downloads source metadata from GitHub and can fail in offline or rate-limited environments. diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml new file mode 100644 index 000000000..5c668a442 --- /dev/null +++ b/examples/governance-interceptor/policy.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + github: + name: github-api-readonly + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/git } + - { path: /usr/bin/curl } + gitlab: + name: gitlab-api-readonly + endpoints: + - host: gitlab.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/git } + - { path: /usr/bin/curl } diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh new file mode 100755 index 000000000..52681f978 --- /dev/null +++ b/examples/governance-interceptor/smoke.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT/examples/governance-interceptor" +TMPDIR="$(mktemp -d)" +JWT_DIR="$TMPDIR/jwt" +LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" +SMOKE_LOG="$LOG_DIR/smoke.log" +INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" +GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" +HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" +DRIVER="${OPENSHELL_GOVERNANCE_SMOKE_DRIVER:-}" +SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" +ROOT_BUILD_ARGS=() +mkdir -p "$LOG_DIR" + +cleanup() { + status=$? + trap - EXIT + if [[ -n "${INTERCEPTOR_PID:-}" ]]; then kill "$INTERCEPTOR_PID" 2>/dev/null || true; fi + if [[ -n "${GATEWAY_PID:-}" ]]; then kill "$GATEWAY_PID" 2>/dev/null || true; fi + if [[ "$status" -eq 0 && "${OPENSHELL_GOVERNANCE_KEEP_LOGS:-0}" != "1" ]]; then + rm -rf "$TMPDIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + exit "$status" +} +trap cleanup EXIT + +log() { + printf '%s\n' "$*" >>"$SMOKE_LOG" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + printf ' smoke log: %s\n' "$SMOKE_LOG" >&2 + printf ' gateway log: %s\n' "$GATEWAY_LOG" >&2 + printf ' interceptor log: %s\n' "$INTERCEPTOR_LOG" >&2 + exit 1 +} + +run_step() { + local label="$1" + shift + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >>"$SMOKE_LOG" 2>&1; then + pass "$label" + else + fail "$label" + fi +} + +expect_failure() { + local label="$1" + shift + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >>"$SMOKE_LOG" 2>&1; then + fail "$label" + else + pass "$label" + fi +} + +expect_output_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -q "$needle" "$output_file"; then + pass "$label" + else + cat "$output_file" >>"$SMOKE_LOG" 2>/dev/null || true + fail "$label" + fi +} + +missing_z3() { + cat >&2 <<'EOF' +No usable local Z3 installation found. + +Install Z3 or point the build at an existing install, then rerun: + brew install z3 + Z3_SYS_Z3_HEADER=/path/to/include/z3.h Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib examples/governance-interceptor/smoke.sh + +The bundled Z3 build downloads source metadata from GitHub and can fail in offline or rate-limited environments. +To opt into that path anyway, set OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1. +EOF + exit 1 +} + +configure_native_build_env() { + if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then + export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" + export CXX="${OPENSHELL_GOVERNANCE_CXX:-clang++}" + log "Using macOS native build compiler: CC=$CC CXX=$CXX" + fi + + if [[ "${OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER:-0}" != "1" ]]; then + export RUSTC_WRAPPER="${OPENSHELL_GOVERNANCE_RUSTC_WRAPPER:-}" + fi + + if [[ -z "${RUSTC_WRAPPER:-}" ]]; then + log "Building without RUSTC_WRAPPER for reproducible smoke builds." + else + log "Using RUSTC_WRAPPER=$RUSTC_WRAPPER" + fi +} + +configure_z3_build_env() { + if [[ -n "${Z3_SYS_Z3_HEADER:-}" || -n "${Z3_LIBRARY_PATH_OVERRIDE:-}" ]]; then + log "Using caller-provided Z3 build environment." + return + fi + + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3 >/dev/null 2>&1; then + log "Using pkg-config Z3 for workspace builds." + return + fi + + z3_prefix="" + if command -v brew >/dev/null 2>&1; then + z3_prefix="$(brew --prefix z3 2>/dev/null || true)" + fi + + for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do + if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then + log "Using local Z3 from ${candidate} for workspace builds." + export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" + export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" + return + fi + done + + if [[ "${OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3:-0}" == "1" ]]; then + log "Falling back to bundled Z3 for workspace builds." + ROOT_BUILD_ARGS+=(--features bundled-z3) + return + fi + + missing_z3 +} + +generate_gateway_jwt_bundle() { + if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is required to generate local smoke-test gateway JWT keys" >&2 + exit 1 + fi + + mkdir -p "$JWT_DIR" + openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 + openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 + printf 'governance-smoke\n' > "$JWT_DIR/kid" +} + +cd "$ROOT" +configure_native_build_env +configure_z3_build_env +generate_gateway_jwt_bundle +run_step "build gateway" cargo build --quiet -p openshell-server --bin openshell-gateway "${ROOT_BUILD_ARGS[@]}" +run_step "build CLI" cargo build --quiet -p openshell-cli --bin openshell "${ROOT_BUILD_ARGS[@]}" +run_step "build governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" + +"$EXAMPLE_DIR/target/debug/governance-interceptor" \ + --listen "$INTERCEPTOR_ADDR" \ + --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & +INTERCEPTOR_PID=$! + +driver_line="" +if [[ -n "$DRIVER" ]]; then + driver_line="compute_drivers = [\"$DRIVER\"]" +fi + +cat > "$TMPDIR/gateway.toml" <"$GATEWAY_LOG" 2>&1 & +GATEWAY_PID=$! + +gateway_ready=0 +for _ in {1..60}; do + if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then + gateway_ready=1 + break + fi + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "gateway starts with interceptor" + fi + sleep 1 +done +if [[ "$gateway_ready" == "1" ]]; then + pass "gateway starts with interceptor" +else + fail "gateway starts with interceptor" +fi + +CLI=("$ROOT/target/debug/openshell" --gateway-endpoint "http://$GATEWAY_ADDR") + +run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy +run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy + +expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy + +run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true +expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" +expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + +expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + +expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + +expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + +run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" + +expect_failure "denies governed provider update" "${CLI[@]}" provider update gitlab --credential GITLAB_TOKEN=changed + +expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github + +echo "ALL PASS governance interceptor smoke" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs new file mode 100644 index 000000000..f9d2c8975 --- /dev/null +++ b/examples/governance-interceptor/src/main.rs @@ -0,0 +1,802 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, +}; +use openshell_core::proto::{ + GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, + SandboxPolicy, +}; +use openshell_policy::parse_sandbox_policy; +use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; +use serde_json::{Map, Number, Value, json}; +use sha2::{Digest, Sha256}; +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +const LABEL_KEY: &str = "openshell.nvidia.com/policy-signature"; +const SERVICE: &str = "openshell.v1.OpenShell"; +const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; + +#[derive(Clone, Debug)] +struct GovernanceInterceptorService { + policy: Value, + policy_signature: String, +} + +impl GovernanceInterceptorService { + fn from_yaml(policy_yaml: &str) -> Result { + let policy = parse_sandbox_policy(policy_yaml) + .map_err(|err| format!("failed to parse policy YAML: {err}"))?; + let policy = sandbox_policy_to_proto_json(&policy); + let policy = normalize_for_struct(policy)?; + let policy_digest: [u8; 32] = Sha256::digest( + serde_json::to_vec(&policy) + .map_err(|err| format!("failed to encode policy JSON: {err}"))?, + ) + .into(); + let policy_signature = format!("sha256-{}", URL_SAFE_NO_PAD.encode(policy_digest)); + Ok(Self { + policy, + policy_signature, + }) + } + + fn manifest() -> InterceptorManifest { + InterceptorManifest { + name: "source-control-governance".to_string(), + failure_policy: "fail_closed".to_string(), + bindings: vec![ + binding( + "govern-create-sandbox", + "CreateSandbox", + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + binding( + "govern-attach-provider", + "AttachSandboxProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-detach-provider", + "DetachSandboxProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-config", + "UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-create-provider", + "CreateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-provider", + "UpdateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-delete-provider", + "DeleteProvider", + &[GatewayInterceptorPhase::Validate], + ), + ], + } + } + + fn evaluate_inner( + &self, + evaluation: &InterceptorEvaluation, + ) -> Result { + let phase = GatewayInterceptorPhase::try_from(evaluation.phase) + .map_err(|_| Status::invalid_argument("unknown interceptor phase"))?; + let operation = evaluation + .operation + .as_ref() + .map(struct_to_json) + .unwrap_or_else(|| Value::Object(Map::new())); + + match (evaluation.method.as_str(), phase) { + ("CreateSandbox", GatewayInterceptorPhase::ModifyOperation) => { + self.patch_create_sandbox(&operation) + } + ("CreateSandbox", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_create_sandbox(&operation)) + } + ( + "AttachSandboxProvider" | "DetachSandboxProvider", + GatewayInterceptorPhase::Validate, + ) => Ok(deny( + "source-control providers are fixed at sandbox creation", + )), + ("UpdateConfig", GatewayInterceptorPhase::Validate) => { + Ok(validate_update_config(&operation)) + } + ("CreateProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_create_provider(&operation)) + } + ("UpdateProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_update_provider(&operation)) + } + ("DeleteProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_delete_provider(&operation)) + } + _ => Ok(allow()), + } + } + + fn patch_create_sandbox(&self, operation: &Value) -> Result { + let mut patches = Vec::new(); + if operation.get("spec").is_some_and(Value::is_object) { + patches.push(json_patch("add", "/spec/policy", self.policy.clone())?); + patches.push(json_patch( + "add", + "/spec/providers", + json!(GOVERNED_PROVIDERS), + )?); + } else { + patches.push(json_patch( + "add", + "/spec", + json!({ + "policy": self.policy, + "providers": GOVERNED_PROVIDERS, + }), + )?); + } + + if operation.get("labels").is_some_and(Value::is_object) { + patches.push(json_patch( + "add", + &format!("/labels/{}", json_pointer_escape(LABEL_KEY)), + Value::String(self.policy_signature.clone()), + )?); + } else { + patches.push(json_patch( + "add", + "/labels", + json!({ LABEL_KEY: self.policy_signature }), + )?); + } + + let mut result = allow(); + result.patches = patches; + result.audit_annotations.insert( + "policy_signature".to_string(), + self.policy_signature.clone(), + ); + Ok(result) + } + + fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { + if operation.pointer("/spec/policy") != Some(&self.policy) { + return deny("sandbox policy must match the source-control governance baseline"); + } + if !providers_are_governed(operation.pointer("/spec/providers")) { + return deny("sandbox providers must be exactly github and gitlab"); + } + if operation + .pointer(&format!("/labels/{}", json_pointer_escape(LABEL_KEY))) + .and_then(Value::as_str) + != Some(self.policy_signature.as_str()) + { + return deny("sandbox is missing the governance policy signature label"); + } + allow() + } +} + +#[tonic::async_trait] +impl GatewayInterceptor for GovernanceInterceptorService { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Self::manifest())) + } + + async fn evaluate( + &self, + request: Request, + ) -> Result, Status> { + self.evaluate_inner(request.get_ref()).map(Response::new) + } +} + +fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + selector: Some(InterceptorSelector { + rpc: format!("{SERVICE}/{method}"), + service: String::new(), + method: String::new(), + }), + phases: phases.iter().map(|phase| *phase as i32).collect(), + failure_policy: "fail_closed".to_string(), + } +} + +fn allow() -> InterceptorResult { + InterceptorResult { + allowed: true, + reason: String::new(), + status_code: String::new(), + patches: Vec::new(), + audit_annotations: HashMap::new(), + } +} + +fn deny(reason: &str) -> InterceptorResult { + InterceptorResult { + allowed: false, + reason: reason.to_string(), + status_code: "PERMISSION_DENIED".to_string(), + patches: Vec::new(), + audit_annotations: HashMap::new(), + } +} + +fn validate_update_config(operation: &Value) -> InterceptorResult { + let has_policy = operation + .get("policy") + .is_some_and(|value| !value.is_null()); + let has_merge_operations = operation + .get("mergeOperations") + .or_else(|| operation.get("merge_operations")) + .and_then(Value::as_array) + .is_some_and(|operations| !operations.is_empty()); + if has_policy || has_merge_operations { + deny("sandbox policy updates are blocked by the governance baseline") + } else { + allow() + } +} + +fn validate_create_provider(operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if is_governed_provider(name) { + allow() + } else { + deny("only github and gitlab provider records may be created") + } +} + +fn validate_update_provider(operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if is_governed_provider(name) { + deny("governed provider records cannot be updated") + } else { + allow() + } +} + +fn validate_delete_provider(operation: &Value) -> InterceptorResult { + let name = operation + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + if is_governed_provider(name) { + deny("governed provider records cannot be deleted") + } else { + allow() + } +} + +fn provider_name(operation: &Value) -> &str { + operation + .pointer("/provider/metadata/name") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn is_governed_provider(name: &str) -> bool { + GOVERNED_PROVIDERS.contains(&name) +} + +fn providers_are_governed(value: Option<&Value>) -> bool { + let Some(Value::Array(providers)) = value else { + return false; + }; + if providers.len() != GOVERNED_PROVIDERS.len() { + return false; + } + GOVERNED_PROVIDERS.iter().all(|provider| { + providers + .iter() + .any(|value| value.as_str() == Some(provider)) + }) +} + +fn json_patch(op: &str, path: &str, value: Value) -> Result { + Ok(JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_proto_value(&value).map_err(Status::internal)?), + from: String::new(), + }) +} + +fn json_pointer_escape(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn normalize_for_struct(value: Value) -> Result { + json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) +} + +fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Value { + let mut out = Map::new(); + out.insert("version".to_string(), json!(policy.version)); + + if let Some(filesystem) = &policy.filesystem { + out.insert( + "filesystem".to_string(), + json!({ + "includeWorkdir": filesystem.include_workdir, + "readOnly": filesystem.read_only, + "readWrite": filesystem.read_write, + }), + ); + } + + if let Some(landlock) = &policy.landlock { + out.insert( + "landlock".to_string(), + json!({ "compatibility": landlock.compatibility }), + ); + } + + if let Some(process) = &policy.process { + out.insert( + "process".to_string(), + json!({ + "runAsUser": process.run_as_user, + "runAsGroup": process.run_as_group, + }), + ); + } + + out.insert( + "networkPolicies".to_string(), + Value::Object( + policy + .network_policies + .iter() + .map(|(key, rule)| (key.clone(), network_rule_to_proto_json(rule))) + .collect(), + ), + ); + + Value::Object(out) +} + +fn network_rule_to_proto_json(rule: &NetworkPolicyRule) -> Value { + json!({ + "name": rule.name, + "endpoints": rule.endpoints.iter().map(endpoint_to_proto_json).collect::>(), + "binaries": rule.binaries.iter().map(|binary| { + json!({ "path": binary.path }) + }).collect::>(), + }) +} + +fn endpoint_to_proto_json(endpoint: &NetworkEndpoint) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "host", &endpoint.host); + insert_u32(&mut out, "port", endpoint.port); + insert_string(&mut out, "protocol", &endpoint.protocol); + insert_string(&mut out, "tls", &endpoint.tls); + insert_string(&mut out, "enforcement", &endpoint.enforcement); + insert_string(&mut out, "access", &endpoint.access); + insert_values( + &mut out, + "rules", + endpoint.rules.iter().map(l7_rule_to_proto_json).collect(), + ); + insert_strings(&mut out, "allowedIps", &endpoint.allowed_ips); + insert_values( + &mut out, + "denyRules", + endpoint + .deny_rules + .iter() + .map(l7_deny_rule_to_proto_json) + .collect(), + ); + insert_u32s(&mut out, "ports", &endpoint.ports); + insert_bool(&mut out, "allowEncodedSlash", endpoint.allow_encoded_slash); + insert_string(&mut out, "persistedQueries", &endpoint.persisted_queries); + if !endpoint.graphql_persisted_queries.is_empty() { + out.insert( + "graphqlPersistedQueries".to_string(), + Value::Object( + endpoint + .graphql_persisted_queries + .iter() + .map(|(key, operation)| { + (key.clone(), graphql_operation_to_proto_json(operation)) + }) + .collect(), + ), + ); + } + insert_u32( + &mut out, + "graphqlMaxBodyBytes", + endpoint.graphql_max_body_bytes, + ); + insert_string(&mut out, "path", &endpoint.path); + insert_bool( + &mut out, + "websocketCredentialRewrite", + endpoint.websocket_credential_rewrite, + ); + insert_bool( + &mut out, + "requestBodyCredentialRewrite", + endpoint.request_body_credential_rewrite, + ); + insert_bool(&mut out, "advisorProposed", endpoint.advisor_proposed); + Value::Object(out) +} + +fn l7_rule_to_proto_json(rule: &L7Rule) -> Value { + let mut out = Map::new(); + if let Some(allow) = &rule.allow { + out.insert("allow".to_string(), l7_allow_to_proto_json(allow)); + } + Value::Object(out) +} + +fn l7_allow_to_proto_json(allow: &L7Allow) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "method", &allow.method); + insert_string(&mut out, "path", &allow.path); + insert_string(&mut out, "command", &allow.command); + insert_query(&mut out, &allow.query); + insert_string(&mut out, "operationType", &allow.operation_type); + insert_string(&mut out, "operationName", &allow.operation_name); + insert_strings(&mut out, "fields", &allow.fields); + Value::Object(out) +} + +fn l7_deny_rule_to_proto_json(rule: &L7DenyRule) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "method", &rule.method); + insert_string(&mut out, "path", &rule.path); + insert_string(&mut out, "command", &rule.command); + insert_query(&mut out, &rule.query); + insert_string(&mut out, "operationType", &rule.operation_type); + insert_string(&mut out, "operationName", &rule.operation_name); + insert_strings(&mut out, "fields", &rule.fields); + Value::Object(out) +} + +fn graphql_operation_to_proto_json(operation: &GraphqlOperation) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "operationType", &operation.operation_type); + insert_string(&mut out, "operationName", &operation.operation_name); + insert_strings(&mut out, "fields", &operation.fields); + Value::Object(out) +} + +fn insert_query( + out: &mut Map, + query: &HashMap, +) { + if query.is_empty() { + return; + } + out.insert( + "query".to_string(), + Value::Object( + query + .iter() + .map(|(key, matcher)| { + let mut value = Map::new(); + insert_string(&mut value, "glob", &matcher.glob); + insert_strings(&mut value, "any", &matcher.any); + (key.clone(), Value::Object(value)) + }) + .collect(), + ), + ); +} + +fn insert_string(out: &mut Map, key: &str, value: &str) { + if !value.is_empty() { + out.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn insert_bool(out: &mut Map, key: &str, value: bool) { + if value { + out.insert(key.to_string(), Value::Bool(value)); + } +} + +fn insert_u32(out: &mut Map, key: &str, value: u32) { + if value != 0 { + out.insert(key.to_string(), json!(value)); + } +} + +fn insert_strings(out: &mut Map, key: &str, values: &[String]) { + if !values.is_empty() { + out.insert(key.to_string(), json!(values)); + } +} + +fn insert_u32s(out: &mut Map, key: &str, values: &[u32]) { + if !values.is_empty() { + out.insert(key.to_string(), json!(values)); + } +} + +fn insert_values(out: &mut Map, key: &str, values: Vec) { + if !values.is_empty() { + out.insert(key.to_string(), Value::Array(values)); + } +} + +fn struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect(), + ) +} + +#[cfg(test)] +fn json_to_struct(value: &Value) -> Result { + let Value::Object(fields) = value else { + return Err("JSON value must be an object".to_string()); + }; + Ok(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }) +} + +fn json_to_proto_value(value: &Value) -> Result { + let kind = match value { + Value::Null => Kind::NullValue(0), + Value::Bool(value) => Kind::BoolValue(*value), + Value::Number(value) => Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| "invalid JSON number".to_string())?, + ), + Value::String(value) => Kind::StringValue(value.clone()), + Value::Array(values) => Kind::ListValue(ListValue { + values: values + .iter() + .map(json_to_proto_value) + .collect::>()?, + }), + Value::Object(fields) => Kind::StructValue(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }), + }; + Ok(ProtoValue { kind: Some(kind) }) +} + +fn proto_value_to_json(value: &ProtoValue) -> Value { + match value.kind.as_ref() { + Some(Kind::NullValue(_)) | None => Value::Null, + Some(Kind::NumberValue(value)) => { + Number::from_f64(*value).map_or(Value::Null, Value::Number) + } + Some(Kind::StringValue(value)) => Value::String(value.clone()), + Some(Kind::BoolValue(value)) => Value::Bool(*value), + Some(Kind::StructValue(value)) => struct_to_json(value), + Some(Kind::ListValue(value)) => { + Value::Array(value.values.iter().map(proto_value_to_json).collect()) + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut listen: SocketAddr = "127.0.0.1:18081".parse()?; + let mut policy_path: Option = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--listen" => { + let value = args.next().ok_or("--listen requires an address")?; + listen = value.parse()?; + } + "--policy" => { + let value = args.next().ok_or("--policy requires a path")?; + policy_path = Some(PathBuf::from(value)); + } + "-h" | "--help" => { + println!("usage: governance-interceptor [--listen ADDR] [--policy FILE]"); + return Ok(()); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + + let policy_yaml = if let Some(path) = policy_path { + tokio::fs::read_to_string(path).await? + } else { + include_str!("../policy.yaml").to_string() + }; + let service = GovernanceInterceptorService::from_yaml(&policy_yaml)?; + + println!("governance interceptor listening on {listen}"); + Server::builder() + .add_service(GatewayInterceptorServer::new(service)) + .serve(listen) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> GovernanceInterceptorService { + GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml")).unwrap() + } + + fn evaluation( + method: &str, + phase: GatewayInterceptorPhase, + operation: Value, + ) -> InterceptorEvaluation { + InterceptorEvaluation { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + service: SERVICE.to_string(), + method: method.to_string(), + phase: phase as i32, + operation: Some(json_to_struct(&operation).unwrap()), + current_state: Some(Struct::default()), + principal: HashMap::new(), + } + } + + #[test] + fn manifest_declares_governance_bindings() { + let manifest = GovernanceInterceptorService::manifest(); + let ids: Vec<_> = manifest + .bindings + .iter() + .map(|binding| binding.id.as_str()) + .collect(); + assert!(ids.contains(&"govern-create-sandbox")); + assert!(ids.contains(&"govern-attach-provider")); + assert!(ids.contains(&"govern-update-config")); + assert_eq!(manifest.failure_policy, "fail_closed"); + } + + #[test] + fn create_sandbox_modify_adds_policy_providers_and_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::ModifyOperation, + json!({"spec": {}, "labels": {"team": "platform"}}), + )) + .unwrap(); + assert!(result.allowed); + let paths: Vec<_> = result + .patches + .iter() + .map(|patch| patch.path.as_str()) + .collect(); + assert!(paths.contains(&"/spec/policy")); + assert!(paths.contains(&"/spec/providers")); + assert!(paths.contains(&"/labels/openshell.nvidia.com~1policy-signature")); + } + + #[test] + fn policy_patch_uses_protobuf_json_names() { + let service = service(); + assert!(service.policy.get("filesystem").is_some()); + assert!(service.policy.get("networkPolicies").is_some()); + assert!(service.policy.get("filesystem_policy").is_none()); + assert!(service.policy.get("network_policies").is_none()); + } + + #[test] + fn provider_creation_is_limited_to_governed_names() { + let service = service(); + let github = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "github"}}}), + )) + .unwrap(); + assert!(github.allowed); + + let slack = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}}}), + )) + .unwrap(); + assert!(!slack.allowed); + } + + #[test] + fn provider_attach_and_detach_are_denied() { + let service = service(); + for method in ["AttachSandboxProvider", "DetachSandboxProvider"] { + let result = service + .evaluate_inner(&evaluation( + method, + GatewayInterceptorPhase::Validate, + json!({"sandboxName": "demo", "providerName": "github"}), + )) + .unwrap(); + assert!(!result.allowed); + } + } + + #[test] + fn policy_update_and_merge_are_denied() { + let service = service(); + for operation in [ + json!({"name": "demo", "policy": {"version": 1}}), + json!({"name": "demo", "mergeOperations": [{"op": "add"}]}), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + } + } + + #[test] + fn governed_provider_update_and_delete_are_denied() { + let service = service(); + let update = service + .evaluate_inner(&evaluation( + "UpdateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "gitlab"}}}), + )) + .unwrap(); + assert!(!update.allowed); + + let delete = service + .evaluate_inner(&evaluation( + "DeleteProvider", + GatewayInterceptorPhase::Validate, + json!({"name": "github"}), + )) + .unwrap(); + assert!(!delete.allowed); + } +} diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto new file mode 100644 index 000000000..ec7164ab1 --- /dev/null +++ b/proto/gateway_interceptor.proto @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.gateway_interceptor.v1; + +import "google/protobuf/struct.proto"; + +// GatewayInterceptor lets an external governance service evaluate gateway +// control-plane operations after OpenShell admission and before or after the +// gateway applies the operation. +service GatewayInterceptor { + // Describe returns the interceptor manifest and declared bindings. + rpc Describe(DescribeRequest) returns (InterceptorManifest); + + // Evaluate returns an allow, deny, or mutation decision for one operation + // phase. + rpc Evaluate(InterceptorEvaluation) returns (InterceptorResult); +} + +message DescribeRequest {} + +enum GatewayInterceptorPhase { + GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; + GATEWAY_INTERCEPTOR_PHASE_PRE_REQUEST = 1; + GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; + GATEWAY_INTERCEPTOR_PHASE_VALIDATE = 3; + GATEWAY_INTERCEPTOR_PHASE_POST_COMMIT = 4; +} + +message InterceptorEvaluation { + // Configured interceptor instance name. + string interceptor_name = 1; + // Manifest binding id selected for this evaluation. + string binding_id = 2; + // Public gRPC service name, e.g. "openshell.v1.OpenShell". + string service = 3; + // Public gRPC method name, e.g. "CreateSandbox". + string method = 4; + // Evaluation phase. + GatewayInterceptorPhase phase = 5; + // Protobuf JSON-shaped operation payload. + google.protobuf.Struct operation = 6; + // Read-only gateway state relevant to the operation. + google.protobuf.Struct current_state = 7; + // Caller identity summary. Values are intentionally non-secret. + map principal = 8; +} + +message InterceptorResult { + // False denies the operation before side effects for pre_request, + // modify_operation, and validate. Post-commit denial is invalid. + bool allowed = 1; + // Human-readable reason for logs and denied gRPC status messages. + string reason = 2; + // Optional gRPC status code name for denials, e.g. "PERMISSION_DENIED". + string status_code = 3; + // RFC 6902 JSON patches. Only valid during modify_operation. + repeated JsonPatch patches = 4; + // Non-secret annotations included in gateway audit logs. + map audit_annotations = 5; +} + +message InterceptorManifest { + // Human-readable interceptor name declared by the service. + string name = 1; + // Bindings declared by the interceptor service. + repeated InterceptorBinding bindings = 2; + // Optional default failure policy for bindings without their own policy. + string failure_policy = 3; +} + +message InterceptorBinding { + // Stable binding id used for config overrides and audit logs. + string id = 1; + // RPC selector. Selectors are intentionally tied to the public API shape. + InterceptorSelector selector = 2; + // Phases this binding wants to evaluate. + repeated GatewayInterceptorPhase phases = 3; + // Optional binding-specific failure policy. + string failure_policy = 4; +} + +message InterceptorSelector { + // Full selector form: "openshell.v1.OpenShell/CreateSandbox". + string rpc = 1; + // Structured service/method form. If rpc is set, it takes precedence. + string service = 2; + string method = 3; +} + +message JsonPatch { + string op = 1; + string path = 2; + google.protobuf.Value value = 3; + string from = 4; +} From ae7ef732277b247ff7bcecbe4375b994e288678e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:36:04 -0700 Subject: [PATCH 2/8] feat(gateway): add service-reflected interceptors Signed-off-by: Drew Newberry --- crates/openshell-cli/src/run.rs | 11 + .../tests/ensure_providers_integration.rs | 2 + .../tests/provider_commands_integration.rs | 2 + .../sandbox_create_lifecycle_integration.rs | 3 + .../sandbox_name_fallback_integration.rs | 1 + crates/openshell-core/src/config.rs | 1 - .../openshell-gateway-interceptors/src/lib.rs | 20 +- crates/openshell-server/src/compute/mod.rs | 4 + crates/openshell-server/src/grpc/auth_rpc.rs | 1 + crates/openshell-server/src/grpc/mod.rs | 2 + crates/openshell-server/src/grpc/policy.rs | 41 ++ crates/openshell-server/src/grpc/provider.rs | 60 +++ crates/openshell-server/src/grpc/sandbox.rs | 74 ++++ crates/openshell-server/src/grpc/service.rs | 2 + .../openshell-server/src/grpc/validation.rs | 36 +- crates/openshell-server/src/inference.rs | 10 + .../openshell-server/src/persistence/tests.rs | 2 + .../openshell-server/src/provider_refresh.rs | 3 + .../openshell-server/src/service_routing.rs | 1 + crates/openshell-server/src/ssh_sessions.rs | 1 + .../src/supervisor_session.rs | 1 + crates/openshell-tui/src/lib.rs | 3 + docs/reference/gateway-config.mdx | 2 +- examples/governance-interceptor/Cargo.lock | 214 ++++++++++ examples/governance-interceptor/Cargo.toml | 3 + examples/governance-interceptor/README.md | 45 +-- examples/governance-interceptor/smoke.sh | 7 - examples/governance-interceptor/src/main.rs | 367 ++++++++++++++++-- proto/datamodel.proto | 9 +- proto/gateway_interceptor.proto | 5 +- proto/openshell.proto | 2 + 31 files changed, 842 insertions(+), 93 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 1c3fd8a82..2d2dbd4ef 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1854,6 +1854,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels: labels.clone(), + annotations: HashMap::new(), }; let response = match client.create_sandbox(request).await { @@ -3353,10 +3354,15 @@ pub async fn sandbox_list( fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { let meta = sandbox.metadata.as_ref(); let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels)); + let annotations = meta.map_or_else( + || serde_json::json!({}), + |m| serde_json::json!(m.annotations), + ); serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), "labels": labels, + "annotations": annotations, "resource_version": meta.map_or(0, |m| m.resource_version), "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), "phase": phase_name(sandbox.phase()), @@ -3809,6 +3815,7 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), @@ -3851,6 +3858,7 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), @@ -4690,6 +4698,7 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.clone(), credentials: credential_map, @@ -5621,6 +5630,7 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: credential_map, @@ -9519,6 +9529,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, + annotations: std::collections::HashMap::new(), }; let provider = Provider { diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4..24ab5e4bb 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,6 +63,7 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -349,6 +350,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a6e53eb1..101210d79 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -127,6 +127,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + annotations: HashMap::new(), }), spec: None, status: None, @@ -602,6 +603,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 207386b84..ffcf4476d 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -87,6 +87,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; @@ -108,6 +109,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; @@ -368,6 +370,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index d4052ff68..5aab059f1 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -79,6 +79,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), ..Default::default() }), diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index df199d832..fd746ba43 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -568,7 +568,6 @@ pub struct GatewayInterceptorBindingOverride { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum GatewayInterceptorPhaseConfig { - PreRequest, ModifyOperation, Validate, PostCommit, diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 2460c499b..c310dee18 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -66,7 +66,6 @@ pub type Result = std::result::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Phase { - PreRequest, ModifyOperation, Validate, PostCommit, @@ -76,7 +75,6 @@ impl Phase { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::PreRequest => "pre_request", Self::ModifyOperation => "modify_operation", Self::Validate => "validate", Self::PostCommit => "post_commit", @@ -86,7 +84,6 @@ impl Phase { #[must_use] pub const fn to_proto(self) -> GatewayInterceptorPhase { match self { - Self::PreRequest => GatewayInterceptorPhase::PreRequest, Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, Self::Validate => GatewayInterceptorPhase::Validate, Self::PostCommit => GatewayInterceptorPhase::PostCommit, @@ -99,7 +96,6 @@ impl TryFrom for Phase { fn try_from(value: GatewayInterceptorPhase) -> Result { match value { - GatewayInterceptorPhase::PreRequest => Ok(Self::PreRequest), GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), GatewayInterceptorPhase::Validate => Ok(Self::Validate), GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), @@ -113,7 +109,6 @@ impl TryFrom for Phase { impl From for Phase { fn from(value: GatewayInterceptorPhaseConfig) -> Self { match value { - GatewayInterceptorPhaseConfig::PreRequest => Self::PreRequest, GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, GatewayInterceptorPhaseConfig::Validate => Self::Validate, GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, @@ -350,14 +345,9 @@ impl GatewayInterceptorRuntime { }; self.routes .is_interceptable(&selector.service, &selector.method) - && [ - Phase::PreRequest, - Phase::ModifyOperation, - Phase::Validate, - Phase::PostCommit, - ] - .iter() - .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + && [Phase::ModifyOperation, Phase::Validate, Phase::PostCommit] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) } pub async fn evaluate_request( @@ -379,9 +369,6 @@ impl GatewayInterceptorRuntime { .decode_message_to_json(&input_type, &frame.message) .map_err(|err| Status::invalid_argument(err.to_string()))?; - operation = self - .evaluate_phase(&selector, Phase::PreRequest, operation, context) - .await?; operation = self .evaluate_phase(&selector, Phase::ModifyOperation, operation, context) .await?; @@ -1875,6 +1862,7 @@ mod tests { }), name: "demo".to_string(), labels: HashMap::from([("team".to_string(), "agent".to_string())]), + annotations: HashMap::new(), }; let bytes = request.encode_to_vec(); let json = descriptors diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index e2fa1f8bc..21af4d094 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1123,6 +1123,7 @@ impl ComputeRuntime { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status, @@ -2312,6 +2313,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() }; @@ -2327,6 +2329,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), @@ -3375,6 +3378,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 88c771bed..944b9b3ed 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,6 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c..a71274e2d 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -115,6 +115,8 @@ const MAX_MAP_VALUE_LEN: usize = 8192; const MAX_TEMPLATE_STRING_LEN: usize = 1024; /// Maximum number of entries in template map fields. const MAX_TEMPLATE_MAP_ENTRIES: usize = 128; +/// Maximum number of entries in metadata annotations. +const MAX_METADATA_ANNOTATIONS_ENTRIES: usize = 128; /// Maximum serialized size (bytes) for template Struct fields. const MAX_TEMPLATE_STRUCT_SIZE: usize = 65_536; /// Maximum serialized size (bytes) for the policy field. diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 770bb71cc..29c055521 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4035,6 +4035,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4068,6 +4069,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4100,6 +4102,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4135,6 +4138,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4222,6 +4226,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4302,6 +4307,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4328,6 +4334,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) @@ -4371,6 +4378,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4442,6 +4450,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -4486,6 +4495,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4551,6 +4561,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4817,6 +4828,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -5101,6 +5113,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -5502,6 +5515,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -5591,6 +5605,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -5695,6 +5710,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -5910,6 +5926,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -6006,6 +6023,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6121,6 +6139,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6324,6 +6343,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6421,6 +6441,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6526,6 +6547,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6619,6 +6641,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6704,6 +6727,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6791,6 +6815,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6878,6 +6903,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6970,6 +6996,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7056,6 +7083,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7152,6 +7180,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7237,6 +7266,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7331,6 +7361,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -7369,6 +7400,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7491,6 +7523,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7677,6 +7710,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -7791,6 +7825,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -7891,6 +7926,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -8005,6 +8041,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -8021,6 +8058,7 @@ mod tests { created_at_ms: 1_000_001, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -9592,6 +9630,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -9667,6 +9706,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -9748,6 +9788,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c90..a13920518 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -78,6 +78,7 @@ pub(super) async fn create_provider_record( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); } @@ -1812,6 +1813,7 @@ fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), profile: Some(profile), } @@ -2146,6 +2148,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2241,6 +2244,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2526,6 +2530,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -2603,6 +2608,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2918,6 +2924,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2980,6 +2987,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: [ @@ -3589,6 +3597,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3624,6 +3633,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3736,6 +3746,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3799,6 +3810,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3842,6 +3854,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -3894,6 +3907,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3916,6 +3930,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) @@ -3935,6 +3950,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -3986,6 +4002,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), @@ -4005,6 +4022,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4071,6 +4089,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4138,6 +4157,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4289,6 +4309,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4411,6 +4432,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4455,6 +4477,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4484,6 +4507,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4518,6 +4542,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4538,6 +4563,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), @@ -4612,6 +4638,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), @@ -4648,6 +4675,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), @@ -4684,6 +4712,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), @@ -4704,6 +4733,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), @@ -4730,6 +4760,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4758,6 +4789,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4805,6 +4837,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), @@ -4856,6 +4889,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4885,6 +4919,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: HashMap::new(), @@ -4916,6 +4951,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), @@ -4944,6 +4980,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), @@ -4961,6 +4998,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) @@ -4992,6 +5030,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: [ @@ -5046,6 +5085,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5095,6 +5135,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5129,6 +5170,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5151,6 +5193,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) @@ -5184,6 +5227,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) @@ -5203,6 +5247,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5241,6 +5286,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5315,6 +5361,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5359,6 +5406,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5407,6 +5455,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5454,6 +5503,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -5492,6 +5542,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5514,6 +5565,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5534,6 +5586,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5552,6 +5605,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5586,6 +5640,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5607,6 +5662,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5643,6 +5699,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5690,6 +5747,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), @@ -6037,6 +6095,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), @@ -6139,6 +6198,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "github".to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 28377394f..c7f691b06 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -133,6 +133,7 @@ async fn handle_create_sandbox_inner( crate::grpc::validation::validate_label_key(key)?; crate::grpc::validation::validate_label_value(value)?; } + crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; let _sandbox_sync_guard = if spec.providers.is_empty() { None @@ -181,6 +182,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, + annotations: request.annotations.clone(), }), spec: Some(spec), status: None, @@ -1360,6 +1362,7 @@ pub(super) async fn handle_create_ssh_session( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -2198,6 +2201,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) @@ -2215,6 +2219,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2585,6 +2590,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2596,6 +2602,73 @@ mod tests { assert!(err.message().contains("provider-b")); } + #[tokio::test] + async fn create_sandbox_persists_long_metadata_annotations() { + let state = test_server_state().await; + let annotation_key = "openshell.nvidia.com/policy-signature".to_string(); + let annotation_value = "x".repeat(512); + + let response = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "annotated".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), + }), + ) + .await + .expect("long annotations should be accepted") + .into_inner(); + + let created = response.sandbox.expect("created sandbox"); + assert_eq!( + created + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + + let fetched = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "annotated".to_string(), + }), + ) + .await + .expect("created sandbox should be fetchable") + .into_inner() + .sandbox + .expect("fetched sandbox"); + assert_eq!( + fetched + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + } + + #[tokio::test] + async fn create_sandbox_still_rejects_long_label_values() { + let state = test_server_state().await; + let err = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "bad-label".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: HashMap::from([("team".to_string(), "x".repeat(512))]), + annotations: HashMap::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("label value exceeds")); + } + #[tokio::test] async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { let state = test_server_state().await; @@ -2617,6 +2690,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 246d639be..01d8dbfe8 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -87,6 +87,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -286,6 +287,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index b3680c6e7..601c6d6fa 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -15,10 +15,10 @@ use prost::Message; use tonic::Status; use super::{ - MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_NAME_LEN, - MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, MAX_PROVIDER_CREDENTIALS_ENTRIES, - MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, - MAX_TEMPLATE_STRUCT_SIZE, + MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, + MAX_METADATA_ANNOTATIONS_ENTRIES, MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, + MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, + MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, }; // --------------------------------------------------------------------------- @@ -257,6 +257,28 @@ pub(super) fn validate_string_map( Ok(()) } +/// Validate object annotations. +/// +/// Annotation keys use the same qualified-key shape as labels. Annotation +/// values are opaque metadata and use the normal string-map size limits rather +/// than Kubernetes label value limits. +pub(super) fn validate_annotations( + annotations: &std::collections::HashMap, + field_name: &str, +) -> Result<(), Status> { + validate_string_map( + annotations, + MAX_METADATA_ANNOTATIONS_ENTRIES, + MAX_MAP_KEY_LEN, + MAX_MAP_VALUE_LEN, + field_name, + )?; + for key in annotations.keys() { + validate_label_key(key)?; + } + Ok(()) +} + /// OPENSHELL_* keys that are allowed in exec environment. The Python SDK's /// `exec_python()` sends a serialized callable via this key. const EXEC_ALLOWED_OPENSHELL_KEYS: &[&str] = &["OPENSHELL_PYFUNC_B64"]; @@ -616,6 +638,11 @@ pub(super) fn validate_object_metadata( validate_label_value(value)?; } + validate_annotations( + &metadata.annotations, + &format!("{resource_type}.metadata.annotations"), + )?; + Ok(()) } @@ -1131,6 +1158,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials, diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 43416c35d..2b58bd15c 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -211,6 +211,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -1030,6 +1031,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: provider_name.to_string(), @@ -1048,6 +1050,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), @@ -1145,6 +1148,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1221,6 +1225,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1269,6 +1274,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1494,6 +1500,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1517,6 +1524,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: "openai-dev".to_string(), @@ -1628,6 +1636,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1965,6 +1974,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index d092b68de..91a24f373 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1243,6 +1243,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, @@ -1282,6 +1283,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c..b604a3ef3 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -200,6 +200,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), provider_id, provider_name, @@ -924,6 +925,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -1172,6 +1174,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba9..1dabb7744 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -804,6 +804,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 752fee1c0..4c6589b9f 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -86,6 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 4adf9e8b6..16ddbc0d4 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,6 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d3..dc5ead7c0 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1360,6 +1360,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }; let sandbox_name = @@ -1615,6 +1616,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype.clone(), credentials: credentials.clone(), @@ -1707,6 +1709,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype, credentials, diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 414574701..d4c897af8 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -144,7 +144,7 @@ max_patches = 32 [[openshell.gateway.interceptors.bindings]] id = "quota-create-sandbox" -phases = ["pre_request", "validate"] +phases = ["modify_operation", "validate"] failure_policy = "fail_closed" [[openshell.gateway.interceptors.bindings]] diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 6a8a15820..6ca15e7f6 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -49,6 +49,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "autotools" version = "0.2.7" @@ -146,6 +152,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" @@ -203,6 +215,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -341,8 +359,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -642,6 +662,32 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.186" @@ -759,6 +805,40 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.37.3" @@ -800,9 +880,12 @@ name = "openshell-governance-interceptor-example" version = "0.0.0" dependencies = [ "base64", + "jsonwebtoken", "openshell-core", "openshell-policy", "prost-types", + "rcgen", + "serde", "serde_json", "sha2", "tokio", @@ -855,6 +938,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -907,6 +1000,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1023,6 +1122,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1140,6 +1252,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -1269,6 +1387,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1405,6 +1535,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1696,6 +1856,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1790,6 +1995,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index ada1f5ecf..a8ff9106c 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -12,9 +12,12 @@ license = "Apache-2.0" [dependencies] base64 = "0.22" +jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } openshell-policy = { path = "../../crates/openshell-policy" } prost-types = "0.14" +rcgen = { version = "0.13", features = ["crypto", "pem"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index c2f93a02e..0f400a95c 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -4,20 +4,33 @@ This standalone example implements the `openshell.gateway_interceptor.v1.Gateway - every new sandbox receives `policy.yaml` - every new sandbox is attached to exactly `github` and `gitlab` -- every new sandbox gets an `openshell.nvidia.com/policy-signature` label +- every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` - users cannot update or delete the governed `github` or `gitlab` provider records +Run these commands from `examples/governance-interceptor`. + Run the interceptor: ```shell -cargo run --manifest-path examples/governance-interceptor/Cargo.toml -- \ +cargo run -- \ --listen 127.0.0.1:18081 \ - --policy examples/governance-interceptor/policy.yaml + --policy policy.yaml ``` +At startup the example parses `policy.yaml`, converts it to the protobuf JSON +shape used by sandbox creation, computes a canonical SHA-256 digest, and signs +that digest as an EdDSA JWT. The interceptor adds that JWT to each governed +sandbox under `metadata.annotations["openshell.nvidia.com/policy-signature"]` and +verifies the JWT against the sandbox policy during the `CreateSandbox` validate +phase. + +The signing key is generated in memory on each interceptor start. This keeps the +example self-contained. Production governance services should load managed +signing keys, publish verifier keys, and define a rotation process. + Gateway TOML snippet: ```toml @@ -34,31 +47,7 @@ max_patches = 32 Run the smoke test against a local gateway and compute driver: ```shell -examples/governance-interceptor/smoke.sh +./smoke.sh ``` The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. - -Set `OPENSHELL_GOVERNANCE_SMOKE_DRIVER=docker|podman|vm|kubernetes` to force a driver. Without it, the gateway uses its existing local driver detection. - -On macOS the smoke script uses `clang`/`clang++` for native dependencies, because Apple SDK headers require Clang block syntax. It also disables `RUSTC_WRAPPER` by default so local `sccache` configuration does not affect the smoke run. - -The workspace build requires Z3. The smoke script uses `pkg-config`, `brew --prefix z3`, `/opt/homebrew/opt/z3`, or `/usr/local/opt/z3` when those locations contain `include/z3.h` and a `lib` directory. If no usable local Z3 install exists, install it first: - -```shell -brew install z3 -``` - -Build overrides: - -```shell -OPENSHELL_GOVERNANCE_CC=/path/to/clang \ -OPENSHELL_GOVERNANCE_CXX=/path/to/clang++ \ -OPENSHELL_GOVERNANCE_RUSTC_WRAPPER=sccache \ -Z3_SYS_Z3_HEADER=/path/to/include/z3.h \ -Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib \ -examples/governance-interceptor/smoke.sh -``` - -Set `OPENSHELL_GOVERNANCE_KEEP_CC=1` or `OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER=1` to preserve the caller environment. -Set `OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1` to opt into the bundled Z3 build, which downloads source metadata from GitHub and can fail in offline or rate-limited environments. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 52681f978..ff967fdcf 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -15,7 +15,6 @@ GATEWAY_LOG="$LOG_DIR/gateway.log" INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" -DRIVER="${OPENSHELL_GOVERNANCE_SMOKE_DRIVER:-}" SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" ROOT_BUILD_ARGS=() mkdir -p "$LOG_DIR" @@ -189,11 +188,6 @@ run_step "build governance interceptor" cargo build --quiet --manifest-path "$EX --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & INTERCEPTOR_PID=$! -driver_line="" -if [[ -n "$DRIVER" ]]; then - driver_line="compute_drivers = [\"$DRIVER\"]" -fi - cat > "$TMPDIR/gateway.toml" <) -> std::fmt::Result { + f.debug_struct("PolicySigner") + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct PolicySignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + policy_sha256: String, +} + +impl PolicySigner { + fn generate() -> Result { + let keypair = KeyPair::generate_for(&PKCS_ED25519) + .map_err(|err| format!("failed to generate policy signing key: {err}"))?; + let signing_key_pem = keypair.serialize_pem(); + let public_key_pem = keypair.public_key_pem(); + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy signing key: {err}"))?; + let decoding_key = DecodingKey::from_ed_pem(public_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy verification key: {err}"))?; + let kid = kid_from_public_key_der(&keypair.public_key_der()); + Ok(Self { + encoding_key, + decoding_key, + kid, + }) + } + + fn kid(&self) -> &str { + &self.kid + } + + fn sign_policy(&self, policy_hash: &str) -> Result { + let claims = PolicySignatureClaims { + sub: POLICY_JWT_SUBJECT.to_string(), + iss: POLICY_JWT_ISSUER.to_string(), + aud: POLICY_JWT_AUDIENCE.to_string(), + iat: now_secs(), + exp: 0, + policy_sha256: policy_hash.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + encode(&header, &claims, &self.encoding_key) + .map_err(|err| format!("failed to sign policy JWT: {err}")) + } + + fn verify_policy_signature(&self, token: &str, policy_hash: &str) -> Result<(), String> { + let header = decode_header(token) + .map_err(|err| format!("failed to decode policy JWT header: {err}"))?; + if header.kid.as_deref() != Some(self.kid.as_str()) { + return Err("unexpected policy signing key id".to_string()); + } + if header.alg != Algorithm::EdDSA { + return Err("unexpected policy signing algorithm".to_string()); + } + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.algorithms = vec![Algorithm::EdDSA]; + validation.set_issuer(&[POLICY_JWT_ISSUER]); + validation.set_audience(&[POLICY_JWT_AUDIENCE]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + validation.validate_exp = false; + + let data = decode::(token, &self.decoding_key, &validation) + .map_err(|err| format!("failed to verify policy JWT: {err}"))?; + if data.claims.sub != POLICY_JWT_SUBJECT { + return Err("unexpected policy JWT subject".to_string()); + } + if data.claims.policy_sha256 != policy_hash { + return Err("signed policy hash does not match sandbox policy".to_string()); + } + Ok(()) + } +} + #[derive(Clone, Debug)] struct GovernanceInterceptorService { policy: Value, + policy_hash: String, policy_signature: String, + policy_signer: PolicySigner, } impl GovernanceInterceptorService { @@ -38,15 +140,14 @@ impl GovernanceInterceptorService { .map_err(|err| format!("failed to parse policy YAML: {err}"))?; let policy = sandbox_policy_to_proto_json(&policy); let policy = normalize_for_struct(policy)?; - let policy_digest: [u8; 32] = Sha256::digest( - serde_json::to_vec(&policy) - .map_err(|err| format!("failed to encode policy JSON: {err}"))?, - ) - .into(); - let policy_signature = format!("sha256-{}", URL_SAFE_NO_PAD.encode(policy_digest)); + let policy_hash = policy_hash(&policy)?; + let policy_signer = PolicySigner::generate()?; + let policy_signature = policy_signer.sign_policy(&policy_hash)?; Ok(Self { policy, + policy_hash, policy_signature, + policy_signer, }) } @@ -158,43 +259,49 @@ impl GovernanceInterceptorService { )?); } - if operation.get("labels").is_some_and(Value::is_object) { - patches.push(json_patch( - "add", - &format!("/labels/{}", json_pointer_escape(LABEL_KEY)), - Value::String(self.policy_signature.clone()), - )?); - } else { - patches.push(json_patch( - "add", - "/labels", - json!({ LABEL_KEY: self.policy_signature }), - )?); - } + add_policy_signature_patches(operation, &mut patches, &self.policy_signature)?; let mut result = allow(); result.patches = patches; + result + .audit_annotations + .insert("policy_hash".to_string(), self.policy_hash.clone()); result.audit_annotations.insert( - "policy_signature".to_string(), - self.policy_signature.clone(), + "policy_signature_kid".to_string(), + self.policy_signer.kid().to_string(), ); Ok(result) } fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { - if operation.pointer("/spec/policy") != Some(&self.policy) { + let Some(policy) = operation.pointer("/spec/policy") else { + return deny("sandbox policy must match the source-control governance baseline"); + }; + let sandbox_policy_hash = match policy_hash(policy) { + Ok(hash) => hash, + Err(err) => return deny(&format!("sandbox policy cannot be hashed: {err}")), + }; + let Some(signature) = operation + .pointer(&format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + else { + return deny("sandbox is missing the governance policy signature"); + }; + if let Err(err) = self + .policy_signer + .verify_policy_signature(signature, &sandbox_policy_hash) + { + return deny(&format!("sandbox policy signature is invalid: {err}")); + } + if sandbox_policy_hash != self.policy_hash || policy != &self.policy { return deny("sandbox policy must match the source-control governance baseline"); } if !providers_are_governed(operation.pointer("/spec/providers")) { return deny("sandbox providers must be exactly github and gitlab"); } - if operation - .pointer(&format!("/labels/{}", json_pointer_escape(LABEL_KEY))) - .and_then(Value::as_str) - != Some(self.policy_signature.as_str()) - { - return deny("sandbox is missing the governance policy signature label"); - } allow() } } @@ -329,6 +436,33 @@ fn json_patch(op: &str, path: &str, value: Value) -> Result { }) } +fn add_policy_signature_patches( + operation: &Value, + patches: &mut Vec, + policy_signature: &str, +) -> Result<(), Status> { + let signature = Value::String(policy_signature.to_string()); + if operation.get("annotations").is_none_or(|value| !value.is_object()) { + patches.push(json_patch( + "add", + "/annotations", + json!({ + POLICY_SIGNATURE_ANNOTATION: policy_signature, + }), + )?); + } else { + patches.push(json_patch( + "add", + &format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + ), + signature, + )?); + } + Ok(()) +} + fn json_pointer_escape(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } @@ -337,6 +471,38 @@ fn normalize_for_struct(value: Value) -> Result { json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) } +fn policy_hash(policy: &Value) -> Result { + let policy = normalize_for_struct(policy.clone())?; + let encoded = serde_json::to_vec(&policy) + .map_err(|err| format!("failed to encode policy JSON: {err}"))?; + let digest: [u8; 32] = Sha256::digest(encoded).into(); + Ok(format!("sha256-{}", URL_SAFE_NO_PAD.encode(digest))) +} + +fn kid_from_public_key_der(public_key_der: &[u8]) -> String { + let digest = Sha256::digest(public_key_der); + hex_encode_prefix(&digest, 16) +} + +fn hex_encode_prefix(bytes: &[u8], n: usize) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(n * 2); + for byte in bytes.iter().take(n) { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn now_secs() -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) + .unwrap_or(i64::MAX) +} + fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Value { let mut out = Map::new(); out.insert("version".to_string(), json!(policy.version)); @@ -679,6 +845,53 @@ mod tests { } } + fn governed_create_operation(policy: Value, signature: String) -> Value { + let mut operation = json!({ + "spec": { + "policy": policy, + "providers": GOVERNED_PROVIDERS, + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(signature), + ); + operation + } + + fn valid_create_operation(service: &GovernanceInterceptorService) -> Value { + governed_create_operation(service.policy.clone(), service.policy_signature.clone()) + } + + fn signature_patch_token(result: &InterceptorResult) -> String { + result + .patches + .iter() + .find(|patch| { + patch.path == "/annotations/openshell.nvidia.com~1policy-signature" + || patch.path == "/annotations" + }) + .and_then(|patch| patch.value.as_ref()) + .map(proto_value_to_json) + .and_then(|value| { + value.as_str().map(ToString::to_string).or_else(|| { + value + .pointer(&format!( + "/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + .map(ToString::to_string) + }) + }) + .expect("signature patch value") + } + #[test] fn manifest_declares_governance_bindings() { let manifest = GovernanceInterceptorService::manifest(); @@ -711,7 +924,99 @@ mod tests { .collect(); assert!(paths.contains(&"/spec/policy")); assert!(paths.contains(&"/spec/providers")); - assert!(paths.contains(&"/labels/openshell.nvidia.com~1policy-signature")); + assert!( + paths.contains(&"/annotations") + || paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature") + ); + let token = signature_patch_token(&result); + assert_eq!(token.split('.').count(), 3); + assert!(result.audit_annotations.contains_key("policy_hash")); + assert!( + result + .audit_annotations + .contains_key("policy_signature_kid") + ); + assert!(!result.audit_annotations.contains_key("policy_signature")); + } + + #[test] + fn create_sandbox_validate_accepts_signed_policy() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + valid_create_operation(&service), + )) + .unwrap(); + assert!(result.allowed); + } + + #[test] + fn create_sandbox_validate_denies_missing_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + json!({ + "spec": { + "policy": service.policy, + "providers": GOVERNED_PROVIDERS, + }, + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("missing")); + } + + #[test] + fn create_sandbox_validate_denies_malformed_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(service.policy.clone(), "not-a-jwt".to_string()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); + } + + #[test] + fn create_sandbox_validate_denies_signature_from_other_key() { + let governance = service(); + let other = service(); + let result = governance + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(governance.policy.clone(), other.policy_signature), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); + } + + #[test] + fn create_sandbox_validate_denies_signed_policy_mismatch() { + let service = service(); + let mut tampered_policy = service.policy.clone(); + tampered_policy + .as_object_mut() + .unwrap() + .insert("version".to_string(), json!(999)); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(tampered_policy, service.policy_signature.clone()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); } #[test] diff --git a/proto/datamodel.proto b/proto/datamodel.proto index f92d7b7a3..cfd6e63c4 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -7,8 +7,9 @@ package openshell.datamodel.v1; // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // -// This structure provides consistent metadata (identity, labels, timestamps, -// resource versioning) across Sandbox, Provider, SshSession, and other resources. +// This structure provides consistent metadata (identity, labels, annotations, +// timestamps, resource versioning) across Sandbox, Provider, SshSession, and +// other resources. message ObjectMeta { // Stable object ID generated by the gateway. string id = 1; @@ -26,6 +27,10 @@ message ObjectMeta { // Optimistic concurrency control version. // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; + + // Opaque key-value metadata that is not used for selectors. + // Annotation keys use the same qualified-key shape as labels, but values may be longer. + map annotations = 6; } // Provider model stored by OpenShell. diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index ec7164ab1..efa8bc88a 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -23,7 +23,6 @@ message DescribeRequest {} enum GatewayInterceptorPhase { GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; - GATEWAY_INTERCEPTOR_PHASE_PRE_REQUEST = 1; GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; GATEWAY_INTERCEPTOR_PHASE_VALIDATE = 3; GATEWAY_INTERCEPTOR_PHASE_POST_COMMIT = 4; @@ -49,8 +48,8 @@ message InterceptorEvaluation { } message InterceptorResult { - // False denies the operation before side effects for pre_request, - // modify_operation, and validate. Post-commit denial is invalid. + // False denies the operation before side effects for modify_operation and + // validate. Post-commit denial is invalid. bool allowed = 1; // Human-readable reason for logs and denied gRPC status messages. string reason = 2; diff --git a/proto/openshell.proto b/proto/openshell.proto index bf803e864..5fdb6970c 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -447,6 +447,8 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // Optional annotations for the sandbox (non-selector metadata). + map annotations = 4; } // Get sandbox request. From 35121097de56d20068cf0a074fe71861afa624d5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:38:51 -0700 Subject: [PATCH 3/8] wip --- examples/governance-interceptor/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 0f400a95c..20e52f3d4 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -10,8 +10,6 @@ This standalone example implements the `openshell.gateway_interceptor.v1.Gateway - users cannot create provider records other than `github` and `gitlab` - users cannot update or delete the governed `github` or `gitlab` provider records -Run these commands from `examples/governance-interceptor`. - Run the interceptor: ```shell From 9916e4db4e7f5f2cd662e3e0fa7aeeb8c0dd9580 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 18:57:12 -0700 Subject: [PATCH 4/8] fix(gateway): harden interceptor evaluation Signed-off-by: Drew Newberry --- .../openshell-gateway-interceptors/src/lib.rs | 249 +++++++++++++++--- crates/openshell-server/src/multiplex.rs | 42 ++- 2 files changed, 251 insertions(+), 40 deletions(-) diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index c310dee18..1b7b0e7b1 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -244,31 +244,37 @@ impl GatewayInterceptorRuntime { for config in configs { validate_service_config(&config)?; let channel = connect_endpoint(&config.grpc_endpoint).await?; + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; let mut client = GatewayInterceptorClient::new(channel.clone()) .max_decoding_message_size( config .max_response_bytes .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), ); - let manifest = client - .describe(Request::new(DescribeRequest {})) - .await - .map_err(|status| { - InterceptorError::Transport(format!( - "Describe failed for '{}': {status}", - config.name - )) - })? - .into_inner(); + let manifest = + tokio::time::timeout(timeout, client.describe(Request::new(DescribeRequest {}))) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "Describe timed out for '{}'", + config.name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); let service_default = config .failure_policy .map(FailurePolicy::from) .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) .unwrap_or(FailurePolicy::FailClosed); - let timeout = match config.timeout.as_deref() { - Some(timeout) => parse_duration(timeout)?, - None => DEFAULT_TIMEOUT, - }; let max_response_bytes = config .max_response_bytes .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); @@ -448,12 +454,15 @@ impl GatewayInterceptorRuntime { if phase == Phase::ModifyOperation && !result.patches.is_empty() { let patch_count = result.patches.len(); - let patch_ops = json_patch_operations(&result.patches) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - patch(&mut operation, &patch_ops).map_err(|err| { - Status::invalid_argument(format!("invalid JSON patch: {err}")) - })?; - emit_evaluation_metrics(plan, "allow", patch_count); + match apply_json_patches(&operation, &result.patches) { + Ok(patched) => { + operation = patched; + emit_evaluation_metrics(plan, "allow", patch_count); + } + Err(err) => { + apply_failure_policy(plan, &err)?; + } + } } else { emit_evaluation_metrics(plan, "allow", 0); } @@ -885,6 +894,14 @@ fn json_patch_operations(patches: &[JsonPatch]) -> Result> { .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) } +fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result { + let patch_ops = json_patch_operations(patches)?; + let mut candidate = operation.clone(); + patch(&mut candidate, &patch_ops) + .map_err(|err| InterceptorError::InvalidResult(format!("invalid JSON patch: {err}")))?; + Ok(candidate) +} + fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { counter!("openshell_gateway_interceptor_evaluations_total").increment(1); if patch_count > 0 { @@ -1041,6 +1058,10 @@ impl ProtoDescriptors { } fn decode_message_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + if let Some(value) = decode_well_known_json(type_name, bytes) { + return value; + } + let message = self.message(type_name)?; let mut values: HashMap> = HashMap::new(); let mut input = bytes; @@ -1243,6 +1264,10 @@ impl ProtoDescriptors { } fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + if let Some(encoded) = encode_well_known_json(type_name, value) { + return encoded; + } + let message = self.message(type_name)?; let Value::Object(map) = value else { return Err(InterceptorError::Transcode(format!( @@ -1589,6 +1614,20 @@ fn json_to_struct(value: Value) -> Result { } } +fn json_to_list_value(value: Value) -> Result { + match value { + Value::Array(values) => Ok(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "google.protobuf.ListValue JSON must be an array".to_string(), + )), + } +} + fn json_to_protobuf_value(value: Value) -> Result { let kind = match value { Value::Null => prost_types::value::Kind::NullValue(0), @@ -1615,22 +1654,76 @@ fn json_to_protobuf_value(value: Value) -> Result { Ok(prost_types::Value { kind: Some(kind) }) } +fn decode_well_known_json(type_name: &str, bytes: &[u8]) -> Option> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => Some( + Struct::decode(bytes) + .map(|value| protobuf_struct_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Struct bytes: {err}" + )) + }), + ), + "google.protobuf.Value" => Some( + prost_types::Value::decode(bytes) + .map(|value| protobuf_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Value bytes: {err}" + )) + }), + ), + "google.protobuf.ListValue" => Some( + prost_types::ListValue::decode(bytes) + .map(|value| protobuf_list_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.ListValue bytes: {err}" + )) + }), + ), + _ => None, + } +} + +fn encode_well_known_json(type_name: &str, value: &Value) -> Option>> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => { + Some(json_to_struct(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.Value" => { + Some(json_to_protobuf_value(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.ListValue" => { + Some(json_to_list_value(value.clone()).map(|value| value.encode_to_vec())) + } + _ => None, + } +} + +fn protobuf_struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) + .collect(), + ) +} + +fn protobuf_list_value_to_json(value: &prost_types::ListValue) -> Value { + Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) +} + fn protobuf_value_to_json(value: &prost_types::Value) -> Value { match value.kind.as_ref() { Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, Some(prost_types::value::Kind::NumberValue(value)) => number_json(*value), Some(prost_types::value::Kind::StringValue(value)) => Value::String(value.clone()), Some(prost_types::value::Kind::BoolValue(value)) => Value::Bool(*value), - Some(prost_types::value::Kind::StructValue(value)) => Value::Object( - value - .fields - .iter() - .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) - .collect(), - ), - Some(prost_types::value::Kind::ListValue(value)) => { - Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) - } + Some(prost_types::value::Kind::StructValue(value)) => protobuf_struct_to_json(value), + Some(prost_types::value::Kind::ListValue(value)) => protobuf_list_value_to_json(value), } } @@ -1842,7 +1935,8 @@ fn json_u32(value: &Value, field: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::{CreateSandboxRequest, SandboxSpec}; + use openshell_core::proto::{CreateSandboxRequest, SandboxSpec, SandboxTemplate}; + use serde_json::json; #[test] fn parses_timeout_suffixes() { @@ -1876,4 +1970,97 @@ mod tests { let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); assert_eq!(decoded, request); } + + #[test] + fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + resources: Some( + json_to_struct(json!({ + "limits": { + "cpu": "2", + "memory": "4Gi" + } + })) + .unwrap(), + ), + driver_config: Some( + json_to_struct(json!({ + "docker": { + "userns": "host" + } + })) + .unwrap(), + ), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::new(), + annotations: HashMap::new(), + }; + + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + + assert_eq!(json["spec"]["template"]["resources"]["limits"]["cpu"], "2"); + assert_eq!( + json["spec"]["template"]["driverConfig"]["docker"]["userns"], + "host" + ); + assert!( + json["spec"]["template"]["resources"] + .get("fields") + .is_none() + ); + + let encoded = descriptors + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[tokio::test] + async fn invalid_modify_patch_honors_fail_open_without_mutating_operation() { + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailOpen, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let operation = json!({ "name": "demo" }); + let result = InterceptorResult { + allowed: true, + patches: vec![JsonPatch { + op: "replace".to_string(), + path: "/missing".to_string(), + value: Some(prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("value".to_string())), + }), + from: String::new(), + }], + ..InterceptorResult::default() + }; + + let err = apply_json_patches(&operation, &result.patches).unwrap_err(); + apply_failure_policy(&plan, &err).unwrap(); + assert_eq!(operation, json!({ "name": "demo" })); + } } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 0fbdc04c0..d9fdc4ed1 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use http::{Extensions, HeaderValue, Request, Response}; use http_body::Body; -use http_body_util::{BodyExt, Full}; +use http_body_util::{BodyExt, Full, LengthLimitError, Limited}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, @@ -123,6 +123,7 @@ macro_rules! request_id_middleware { /// bound memory allocation from a single request. Sandbox creation is /// the largest payload and well within this cap under normal use. const MAX_GRPC_DECODE_SIZE: usize = 1_048_576; +const MAX_INTERCEPTED_GRPC_BODY_SIZE: usize = MAX_GRPC_DECODE_SIZE + 5; /// Multiplexed gRPC/HTTP service. #[derive(Clone)] @@ -269,14 +270,9 @@ where let context = gateway_interceptor_context(req.extensions()); let (parts, body) = req.into_parts(); - let body = match body.collect().await { - Ok(collected) => collected.to_bytes(), - Err(err) => { - return Ok(tonic::Status::internal(format!( - "failed to read gRPC request body for interceptor evaluation: {err}" - )) - .into_http()); - } + let body = match collect_intercepted_grpc_body(body).await { + Ok(body) => body, + Err(status) => return Ok(status.into_http()), }; let intercepted = match interceptors.evaluate_request(&path, &body, &context).await { @@ -303,6 +299,24 @@ where } } +async fn collect_intercepted_grpc_body(body: BoxBody) -> Result { + Limited::new(body, MAX_INTERCEPTED_GRPC_BODY_SIZE) + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .map_err(|err| { + if err.downcast_ref::().is_some() { + tonic::Status::resource_exhausted(format!( + "gRPC request body exceeds interceptor evaluation limit of {MAX_INTERCEPTED_GRPC_BODY_SIZE} bytes" + )) + } else { + tonic::Status::internal(format!( + "failed to read gRPC request body for interceptor evaluation: {err}" + )) + } + }) +} + fn boxed_body_from_bytes(bytes: Bytes) -> BoxBody { let body = Full::new(bytes) .map_err(|never: Infallible| -> Box { match never {} }) @@ -1040,6 +1054,16 @@ mod tests { sender.send_request(req).await.unwrap() } + #[tokio::test] + async fn intercepted_grpc_body_collection_rejects_oversized_body() { + let oversized = Bytes::from(vec![0_u8; MAX_INTERCEPTED_GRPC_BODY_SIZE + 1]); + let status = collect_intercepted_grpc_body(boxed_body_from_bytes(oversized)) + .await + .expect_err("oversized body should be rejected"); + + assert_eq!(status.code(), tonic::Code::ResourceExhausted); + } + #[tokio::test] async fn http_response_includes_request_id() { let addr = start_http_server_with_middleware().await; From 8f586d82b1e8dce1e71779e077fe1154a96fd2c4 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 25 Jun 2026 09:10:10 -0700 Subject: [PATCH 5/8] feat(interceptors): label metrics and harden governance smoke Signed-off-by: Drew Newberry --- .../openshell-gateway-interceptors/src/lib.rs | 17 +- examples/governance-interceptor/README.md | 12 +- examples/governance-interceptor/smoke.sh | 220 +++++++++++++----- 3 files changed, 186 insertions(+), 63 deletions(-) diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 1b7b0e7b1..c8d82566f 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -902,10 +902,21 @@ fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result Ok(candidate) } -fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { - counter!("openshell_gateway_interceptor_evaluations_total").increment(1); +fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) { + counter!( + "openshell_gateway_interceptor_evaluations_total", + "decision" => result.to_string(), + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(1); if patch_count > 0 { - counter!("openshell_gateway_interceptor_patches_total").increment(patch_count as u64); + counter!( + "openshell_gateway_interceptor_patches_total", + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(patch_count as u64); } } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 20e52f3d4..c4ac7f1d3 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -1,10 +1,13 @@ # Governance Interceptor Example -This standalone example implements the `openshell.gateway_interceptor.v1.GatewayInterceptor` service. It enforces a source-control governance baseline: +This standalone example implements the +`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates how to +extend OpenShell to provide advanced governance over sandbox policies. -- every new sandbox receives `policy.yaml` +- every new sandbox receives `policy.yaml` sourced from this examples folder - every new sandbox is attached to exactly `github` and `gitlab` - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation + that is used to verify the policy - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` @@ -42,10 +45,9 @@ max_response_bytes = 1048576 max_patches = 32 ``` -Run the smoke test against a local gateway and compute driver: +Run the smoke test script to automatically start the gateway, interceptor, and test the +governance controls ```shell ./smoke.sh ``` - -The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index ff967fdcf..3ce6b6cc2 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -7,16 +7,48 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" EXAMPLE_DIR="$ROOT/examples/governance-interceptor" TMPDIR="$(mktemp -d)" +SMOKE_RUN_ID="${OPENSHELL_GOVERNANCE_RUN_ID:-governance-smoke-$$-$RANDOM}" +port_is_free() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + else + ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + fi +} + +choose_port_block() { + local count="$1" + local start offset ok + for _ in {1..200}; do + start=$((20000 + RANDOM % 20000)) + ok=1 + for ((offset = 0; offset < count; offset++)); do + if ! port_is_free "$((start + offset))"; then + ok=0 + break + fi + done + if [[ "$ok" == "1" ]]; then + printf '%s\n' "$start" + return + fi + done + echo "failed to find free local ports for smoke test" >&2 + exit 1 +} + +DEFAULT_PORT_BASE="$(choose_port_block 3)" JWT_DIR="$TMPDIR/jwt" LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" SMOKE_LOG="$LOG_DIR/smoke.log" INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" GATEWAY_LOG="$LOG_DIR/gateway.log" -INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" -GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" -HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" -SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" -ROOT_BUILD_ARGS=() +INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:$DEFAULT_PORT_BASE}" +GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 1))}" +HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 2))}" +GATEWAY_ID="${OPENSHELL_GOVERNANCE_GATEWAY_ID:-$SMOKE_RUN_ID}" +SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-$SMOKE_RUN_ID-sandbox}" mkdir -p "$LOG_DIR" cleanup() { @@ -43,12 +75,47 @@ pass() { fail() { printf 'FAIL %s\n' "$1" >&2 - printf ' smoke log: %s\n' "$SMOKE_LOG" >&2 - printf ' gateway log: %s\n' "$GATEWAY_LOG" >&2 - printf ' interceptor log: %s\n' "$INTERCEPTOR_LOG" >&2 + dump_logs exit 1 } +setup_fail() { + printf 'ERROR %s\n' "$1" >&2 + dump_logs + exit 1 +} + +dump_log_file() { + local label="$1" + local path="$2" + printf '\n--- %s: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 + else + printf '(missing)\n' >&2 + fi +} + +dump_logs() { + dump_log_file "smoke log" "$SMOKE_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" +} + +run_setup_step() { + local label="$1" + shift + printf 'INFO %s\n' "$label" + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if ! "$@" >>"$SMOKE_LOG" 2>&1; then + setup_fail "$label" + fi +} + run_step() { local label="$1" shift @@ -97,20 +164,6 @@ expect_output_contains() { fi } -missing_z3() { - cat >&2 <<'EOF' -No usable local Z3 installation found. - -Install Z3 or point the build at an existing install, then rerun: - brew install z3 - Z3_SYS_Z3_HEADER=/path/to/include/z3.h Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib examples/governance-interceptor/smoke.sh - -The bundled Z3 build downloads source metadata from GitHub and can fail in offline or rate-limited environments. -To opt into that path anyway, set OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1. -EOF - exit 1 -} - configure_native_build_env() { if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" @@ -129,38 +182,56 @@ configure_native_build_env() { fi } -configure_z3_build_env() { - if [[ -n "${Z3_SYS_Z3_HEADER:-}" || -n "${Z3_LIBRARY_PATH_OVERRIDE:-}" ]]; then - log "Using caller-provided Z3 build environment." +docker_socket_responds() { + local socket="$1" + curl --silent --fail --unix-socket "$socket" http://localhost/_ping >/dev/null 2>&1 +} + +docker_context_socket() { + if ! command -v docker >/dev/null 2>&1; then return fi - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3 >/dev/null 2>&1; then - log "Using pkg-config Z3 for workspace builds." + local endpoint + endpoint="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)" + if [[ "$endpoint" == unix://* ]]; then + printf '%s\n' "${endpoint#unix://}" + fi +} + +configure_container_runtime_env() { + if [[ -n "${DOCKER_HOST:-}" ]]; then + log "Using caller-provided DOCKER_HOST=$DOCKER_HOST" return fi - z3_prefix="" - if command -v brew >/dev/null 2>&1; then - z3_prefix="$(brew --prefix z3 2>/dev/null || true)" + local candidate + local -a candidates=() + + candidate="$(docker_context_socket)" + if [[ -n "$candidate" ]]; then + candidates+=("$candidate") + fi + + if [[ -n "${HOME:-}" ]]; then + candidates+=( + "$HOME/.colima/default/docker.sock" + "$HOME/.colima/docker.sock" + "$HOME/.docker/run/docker.sock" + ) fi - for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do - if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then - log "Using local Z3 from ${candidate} for workspace builds." - export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" - export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" + candidates+=("/var/run/docker.sock") + + for candidate in "${candidates[@]}"; do + if [[ -S "$candidate" ]] && docker_socket_responds "$candidate"; then + export DOCKER_HOST="unix://$candidate" + log "Using Docker socket from $DOCKER_HOST for workspace builds and gateway runtime." return fi done - if [[ "${OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3:-0}" == "1" ]]; then - log "Falling back to bundled Z3 for workspace builds." - ROOT_BUILD_ARGS+=(--features bundled-z3) - return - fi - - missing_z3 + log "No reachable Docker socket detected; relying on gateway driver autodetection." } generate_gateway_jwt_bundle() { @@ -172,16 +243,49 @@ generate_gateway_jwt_bundle() { mkdir -p "$JWT_DIR" openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 - printf 'governance-smoke\n' > "$JWT_DIR/kid" + printf '%s\n' "$GATEWAY_ID" > "$JWT_DIR/kid" +} + +start_dedicated_gateway() { + printf 'INFO starting dedicated gateway\n' + log "Starting dedicated gateway id=$GATEWAY_ID endpoint=http://$GATEWAY_ADDR health=http://$HEALTH_ADDR" + env \ + -u OPENSHELL_GATEWAY_CONFIG \ + -u OPENSHELL_BIND_ADDRESS \ + -u OPENSHELL_SERVER_PORT \ + -u OPENSHELL_HEALTH_PORT \ + -u OPENSHELL_METRICS_PORT \ + -u OPENSHELL_LOG_LEVEL \ + -u OPENSHELL_TLS_CERT \ + -u OPENSHELL_TLS_KEY \ + -u OPENSHELL_TLS_CLIENT_CA \ + -u OPENSHELL_LOCAL_TLS_DIR \ + -u OPENSHELL_DRIVERS \ + -u OPENSHELL_DISABLE_TLS \ + -u OPENSHELL_OIDC_ISSUER \ + -u OPENSHELL_ENABLE_MTLS_AUTH \ + -u OPENSHELL_OIDC_AUDIENCE \ + -u OPENSHELL_OIDC_JWKS_TTL \ + -u OPENSHELL_OIDC_ROLES_CLAIM \ + -u OPENSHELL_OIDC_ADMIN_ROLE \ + -u OPENSHELL_OIDC_USER_ROLE \ + -u OPENSHELL_OIDC_SCOPES_CLAIM \ + -u OPENSHELL_GRPC_RATE_LIMIT_REQUESTS \ + -u OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS \ + -u OPENSHELL_SERVER_SAN \ + -u OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP \ + "OPENSHELL_DB_URL=sqlite://$TMPDIR/gateway.db" \ + "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! } cd "$ROOT" configure_native_build_env -configure_z3_build_env +configure_container_runtime_env generate_gateway_jwt_bundle -run_step "build gateway" cargo build --quiet -p openshell-server --bin openshell-gateway "${ROOT_BUILD_ARGS[@]}" -run_step "build CLI" cargo build --quiet -p openshell-cli --bin openshell "${ROOT_BUILD_ARGS[@]}" -run_step "build governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell +run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" "$EXAMPLE_DIR/target/debug/governance-interceptor" \ --listen "$INTERCEPTOR_ADDR" \ @@ -205,7 +309,7 @@ allow_unauthenticated_users = true signing_key_path = "$JWT_DIR/signing.pem" public_key_path = "$JWT_DIR/public.pem" kid_path = "$JWT_DIR/kid" -gateway_id = "governance-smoke" +gateway_id = "$GATEWAY_ID" ttl_secs = 0 [[openshell.gateway.interceptors]] @@ -218,19 +322,17 @@ max_response_bytes = 1048576 max_patches = 32 EOF -OPENSHELL_DB_URL="sqlite://$TMPDIR/gateway.db" \ - "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & -GATEWAY_PID=$! +start_dedicated_gateway gateway_ready=0 for _ in {1..60}; do + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "gateway starts with interceptor" + fi if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then gateway_ready=1 break fi - if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then - fail "gateway starts with interceptor" - fi sleep 1 done if [[ "$gateway_ready" == "1" ]]; then @@ -239,7 +341,15 @@ else fail "gateway starts with interceptor" fi -CLI=("$ROOT/target/debug/openshell" --gateway-endpoint "http://$GATEWAY_ADDR") +CLI=( + env + -u OPENSHELL_GATEWAY + -u OPENSHELL_GATEWAY_ENDPOINT + -u OPENSHELL_GATEWAY_INSECURE + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "http://$GATEWAY_ADDR" +) run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy From ede2d170367f639a25d248a0ae09bdb591b361ee Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 09:02:18 -0700 Subject: [PATCH 6/8] remove on_error: ignore --- crates/openshell-core/src/config.rs | 16 +++- .../openshell-gateway-interceptors/src/lib.rs | 73 +++++++++++-------- docs/reference/gateway-config.mdx | 2 +- proto/gateway_interceptor.proto | 2 + 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index fd746ba43..fc4be801e 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -534,7 +534,6 @@ pub struct GatewayInterceptorConfig { pub enum GatewayInterceptorFailurePolicy { FailClosed, FailOpen, - Ignore, } /// Configured override for a manifest binding. @@ -855,9 +854,9 @@ mod tests { #[cfg(unix)] use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, detect_driver, - docker_host_unix_socket_path, is_unix_socket, podman_socket_candidates_from_env, - podman_socket_responds, + ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorFailurePolicy, + GatewayJwtConfig, detect_driver, docker_host_unix_socket_path, is_unix_socket, + podman_socket_candidates_from_env, podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -923,6 +922,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn gateway_interceptor_failure_policy_rejects_ignore() { + let err = + serde_json::from_value::(serde_json::json!("ignore")) + .unwrap_err(); + + assert!(err.to_string().contains("unknown variant `ignore`")); + } + #[test] fn grpc_rate_limit_requires_positive_pair() { assert!(Config::new(None).grpc_rate_limit().is_none()); diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index c8d82566f..004c5a9e3 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -41,7 +41,7 @@ use tonic::codegen::http::Uri; use tonic::transport::{Channel, Endpoint}; use tonic::{Code, Request, Status}; use tower::service_fn; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; pub mod routes; @@ -120,7 +120,6 @@ impl From for Phase { pub enum FailurePolicy { FailClosed, FailOpen, - Ignore, } impl FailurePolicy { @@ -129,7 +128,6 @@ impl FailurePolicy { match self { Self::FailClosed => "fail_closed", Self::FailOpen => "fail_open", - Self::Ignore => "ignore", } } } @@ -139,7 +137,6 @@ impl From for FailurePolicy { match value { GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, - GatewayInterceptorFailurePolicy::Ignore => Self::Ignore, } } } @@ -270,10 +267,11 @@ impl GatewayInterceptorRuntime { )) })? .into_inner(); + let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; let service_default = config .failure_policy .map(FailurePolicy::from) - .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) + .or(manifest_default) .unwrap_or(FailurePolicy::FailClosed); let max_response_bytes = config .max_response_bytes @@ -302,14 +300,6 @@ impl GatewayInterceptorRuntime { ))); } for phase in normalized.phases { - if normalized.failure_policy == FailurePolicy::Ignore - && phase != Phase::PostCommit - { - return Err(InterceptorError::Config(format!( - "interceptor '{}' binding '{}' uses failure_policy=ignore outside post_commit", - config.name, normalized.binding_id - ))); - } let plan = BindingPlan { interceptor_name: config.name.clone(), binding_id: normalized.binding_id.clone(), @@ -571,7 +561,7 @@ fn normalize_binding( } let mut failure_policy = - parse_failure_policy(binding.failure_policy.as_str()).unwrap_or(service_default); + parse_optional_failure_policy(&binding.failure_policy)?.unwrap_or(service_default); if let Some(override_cfg) = overrides.get(binding_id, &selector) { if let Some(override_selector) = override_selector(override_cfg)? @@ -667,14 +657,11 @@ fn parse_rpc_selector(value: &str) -> Result { Ok(RpcSelector::new(service, method)) } -fn parse_failure_policy(value: &str) -> Result { +fn parse_optional_failure_policy(value: &str) -> Result> { match value.trim() { - "" => Err(InterceptorError::Config( - "failure_policy must not be empty".to_string(), - )), - "fail_closed" => Ok(FailurePolicy::FailClosed), - "fail_open" => Ok(FailurePolicy::FailOpen), - "ignore" => Ok(FailurePolicy::Ignore), + "" => Ok(None), + "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), + "fail_open" => Ok(Some(FailurePolicy::FailOpen)), other => Err(InterceptorError::Config(format!( "unsupported failure_policy '{other}'" ))), @@ -813,16 +800,6 @@ fn apply_failure_policy( counter!("openshell_gateway_interceptor_fail_open_total").increment(1); Ok(()) } - FailurePolicy::Ignore => { - debug!( - interceptor = %plan.interceptor_name, - binding_id = %plan.binding_id, - phase = plan.phase.as_str(), - error = %err, - "gateway interceptor failure ignored" - ); - Ok(()) - } } } @@ -1956,6 +1933,40 @@ mod tests { assert!(parse_duration("2").is_err()); } + #[test] + fn service_default_failure_policy_rejects_ignore() { + let err = parse_optional_failure_policy("ignore").unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + + #[test] + fn binding_failure_policy_rejects_ignore() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "binding".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::Validate as i32], + failure_policy: "ignore".to_string(), + }; + + let err = normalize_binding("test", &binding, FailurePolicy::FailClosed, &override_index) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + #[test] fn dynamic_create_sandbox_round_trip_uses_json_names() { let descriptors = diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index d4c897af8..f50a6459a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -160,7 +160,7 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target unary OpenShell methods that are not on the built-in supervisor, streaming, read-only, or introspection allowlist. Request bodies are exposed as protobuf JSON objects, so adding a new unary RPC does not require handler-specific interceptor code. -`failure_policy` accepts `fail_closed`, `fail_open`, or `ignore`. Use `ignore` only for `post_commit` bindings. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. +`failure_policy` accepts `fail_closed` or `fail_open`. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index efa8bc88a..28e6785b3 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -67,6 +67,7 @@ message InterceptorManifest { // Bindings declared by the interceptor service. repeated InterceptorBinding bindings = 2; // Optional default failure policy for bindings without their own policy. + // Supported values are "fail_closed" and "fail_open". string failure_policy = 3; } @@ -78,6 +79,7 @@ message InterceptorBinding { // Phases this binding wants to evaluate. repeated GatewayInterceptorPhase phases = 3; // Optional binding-specific failure policy. + // Supported values are "fail_closed" and "fail_open". string failure_policy = 4; } From b21e10406e312f609bb3ee4a859e3663527349cd Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 11:07:57 -0700 Subject: [PATCH 7/8] feat(gateway-interceptors): emit log annotations Signed-off-by: Drew Newberry --- Cargo.lock | 1 + .../openshell-gateway-interceptors/Cargo.toml | 3 + .../openshell-gateway-interceptors/src/lib.rs | 88 +++++++++++++++++++ examples/governance-interceptor/README.md | 9 ++ examples/governance-interceptor/smoke.sh | 15 +++- examples/governance-interceptor/src/main.rs | 43 ++++++--- proto/gateway_interceptor.proto | 4 +- 7 files changed, 149 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb6ab1443..f03546a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,6 +3591,7 @@ dependencies = [ "tonic", "tower 0.5.3", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index f38b055bb..7800d6628 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -26,5 +26,8 @@ tonic = { workspace = true, features = ["channel", "tls-native-roots"] } tower = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tracing-subscriber = { workspace = true } + [lints] workspace = true diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 004c5a9e3..73eb4355a 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -439,6 +439,7 @@ impl GatewayInterceptorRuntime { result.reason.clone() }; emit_evaluation_metrics(plan, "deny", 0); + emit_evaluation_log(plan, &result, "deny", 0); return Err(status_from_result(&result, reason)); } @@ -448,6 +449,7 @@ impl GatewayInterceptorRuntime { Ok(patched) => { operation = patched; emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, &result, "allow", patch_count); } Err(err) => { apply_failure_policy(plan, &err)?; @@ -455,6 +457,7 @@ impl GatewayInterceptorRuntime { } } else { emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, &result, "allow", 0); } } Ok(operation) @@ -897,6 +900,25 @@ fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) } } +fn emit_evaluation_log( + plan: &BindingPlan, + result: &InterceptorResult, + decision: &str, + patch_count: usize, +) { + info!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + service = %plan.selector.service, + method = %plan.selector.method, + decision, + patch_count, + log_annotations = ?result.log_annotations, + "gateway interceptor evaluated" + ); +} + #[derive(Debug, Clone)] struct GrpcFrame { compressed: bool, @@ -1925,6 +1947,22 @@ mod tests { use super::*; use openshell_core::proto::{CreateSandboxRequest, SandboxSpec, SandboxTemplate}; use serde_json::json; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::layer::SubscriberExt; + + #[derive(Clone)] + struct TraceBuf(Arc>>); + + impl std::io::Write for TraceBuf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } #[test] fn parses_timeout_suffixes() { @@ -1967,6 +2005,56 @@ mod tests { ); } + #[tokio::test] + async fn evaluation_log_emits_structured_log_annotations() { + let log_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let writer = TraceBuf(log_buf.clone()); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .without_time(); + let subscriber = tracing_subscriber::registry().with(fmt_layer); + let dispatch = tracing::Dispatch::new(subscriber); + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailClosed, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let result = InterceptorResult { + allowed: true, + log_annotations: HashMap::from([ + ( + "correlation_id".to_string(), + "governance:create-sandbox:demo".to_string(), + ), + ("policy_hash".to_string(), "abc123".to_string()), + ]), + ..InterceptorResult::default() + }; + + tracing::dispatcher::with_default(&dispatch, || { + emit_evaluation_log(&plan, &result, "allow", 2); + }); + + let output = String::from_utf8(log_buf.lock().unwrap().clone()).unwrap(); + assert!(output.contains("gateway interceptor evaluated")); + assert!(output.contains("log_annotations")); + assert!(output.contains("correlation_id")); + assert!(output.contains("governance:create-sandbox:demo")); + assert!(output.contains("policy_hash")); + } + #[test] fn dynamic_create_sandbox_round_trip_uses_json_names() { let descriptors = diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index c4ac7f1d3..62fdfca6b 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -8,6 +8,8 @@ extend OpenShell to provide advanced governance over sandbox policies. - every new sandbox is attached to exactly `github` and `gitlab` - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation that is used to verify the policy +- every sandbox creation evaluation adds a `correlation_id` log annotation so the + gateway log can be correlated with interceptor-side decisions - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` @@ -32,6 +34,13 @@ The signing key is generated in memory on each interceptor start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. +Interceptors can also attach non-secret operational metadata to +`InterceptorResult.log_annotations`. The gateway logs that map as structured +interceptor metadata for each successful evaluation. This example adds +`correlation_id = "governance:create-sandbox:"` during +`CreateSandbox` modification alongside the policy hash and signing key ID. Do +not put secrets, tokens, or policy signatures in log annotations. + Gateway TOML snippet: ```toml diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 3ce6b6cc2..b16342fbc 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -164,6 +164,17 @@ expect_output_contains() { fi } +expect_log_contains() { + local label="$1" + local needle="$2" + local path="$3" + if grep -q "$needle" "$path"; then + pass "$label" + else + fail "$label" + fi +} + configure_native_build_env() { if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" @@ -300,7 +311,7 @@ version = 1 bind_address = "$GATEWAY_ADDR" health_bind_address = "$HEALTH_ADDR" disable_tls = true -log_level = "warn" +log_level = "info" [openshell.gateway.auth] allow_unauthenticated_users = true @@ -357,6 +368,8 @@ run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitl expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true +expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" +expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index c2a862ebe..6e1e016e5 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -32,6 +32,7 @@ const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature const POLICY_JWT_ISSUER: &str = "openshell-governance-interceptor"; const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; const POLICY_JWT_SUBJECT: &str = "policy.yaml"; +const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; const SERVICE: &str = "openshell.v1.OpenShell"; const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; @@ -263,10 +264,14 @@ impl GovernanceInterceptorService { let mut result = allow(); result.patches = patches; + result.log_annotations.insert( + "correlation_id".to_string(), + create_sandbox_correlation_id(operation), + ); result - .audit_annotations + .log_annotations .insert("policy_hash".to_string(), self.policy_hash.clone()); - result.audit_annotations.insert( + result.log_annotations.insert( "policy_signature_kid".to_string(), self.policy_signer.kid().to_string(), ); @@ -336,13 +341,23 @@ fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> Interc } } +fn create_sandbox_correlation_id(operation: &Value) -> String { + let sandbox_name = operation + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unnamed"); + format!("{CREATE_SANDBOX_CORRELATION_PREFIX}:{sandbox_name}") +} + fn allow() -> InterceptorResult { InterceptorResult { allowed: true, reason: String::new(), status_code: String::new(), patches: Vec::new(), - audit_annotations: HashMap::new(), + log_annotations: HashMap::new(), } } @@ -352,7 +367,7 @@ fn deny(reason: &str) -> InterceptorResult { reason: reason.to_string(), status_code: "PERMISSION_DENIED".to_string(), patches: Vec::new(), - audit_annotations: HashMap::new(), + log_annotations: HashMap::new(), } } @@ -442,7 +457,10 @@ fn add_policy_signature_patches( policy_signature: &str, ) -> Result<(), Status> { let signature = Value::String(policy_signature.to_string()); - if operation.get("annotations").is_none_or(|value| !value.is_object()) { + if operation + .get("annotations") + .is_none_or(|value| !value.is_object()) + { patches.push(json_patch( "add", "/annotations", @@ -913,7 +931,7 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::ModifyOperation, - json!({"spec": {}, "labels": {"team": "platform"}}), + json!({"name": "demo", "spec": {}, "labels": {"team": "platform"}}), )) .unwrap(); assert!(result.allowed); @@ -930,13 +948,16 @@ mod tests { ); let token = signature_patch_token(&result); assert_eq!(token.split('.').count(), 3); - assert!(result.audit_annotations.contains_key("policy_hash")); - assert!( + assert_eq!( result - .audit_annotations - .contains_key("policy_signature_kid") + .log_annotations + .get("correlation_id") + .map(String::as_str), + Some("governance:create-sandbox:demo") ); - assert!(!result.audit_annotations.contains_key("policy_signature")); + assert!(result.log_annotations.contains_key("policy_hash")); + assert!(result.log_annotations.contains_key("policy_signature_kid")); + assert!(!result.log_annotations.contains_key("policy_signature")); } #[test] diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 28e6785b3..26f3a411c 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -57,8 +57,8 @@ message InterceptorResult { string status_code = 3; // RFC 6902 JSON patches. Only valid during modify_operation. repeated JsonPatch patches = 4; - // Non-secret annotations included in gateway audit logs. - map audit_annotations = 5; + // Non-secret annotations included in gateway logs. + map log_annotations = 5; } message InterceptorManifest { From e06ec5725fe8ab4837d550cbb4bf7c60be2c7c8e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 17:04:50 -0700 Subject: [PATCH 8/8] feat(examples): govern provider profiles in interceptor Signed-off-by: Drew Newberry --- examples/governance-interceptor/Cargo.lock | 21 + examples/governance-interceptor/Cargo.toml | 2 + examples/governance-interceptor/README.md | 35 +- examples/governance-interceptor/policy.yaml | 24 +- .../profiles/github.yaml | 33 + .../profiles/slack.yaml | 36 + examples/governance-interceptor/smoke.sh | 425 ++++++----- examples/governance-interceptor/src/main.rs | 676 ++++++++++++++++-- 8 files changed, 932 insertions(+), 320 deletions(-) create mode 100644 examples/governance-interceptor/profiles/github.yaml create mode 100644 examples/governance-interceptor/profiles/slack.yaml diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 6ca15e7f6..137db87eb 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -382,6 +382,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.15" @@ -883,10 +889,12 @@ dependencies = [ "jsonwebtoken", "openshell-core", "openshell-policy", + "openshell-providers", "prost-types", "rcgen", "serde", "serde_json", + "serde_yml", "sha2", "tokio", "tonic", @@ -903,6 +911,19 @@ dependencies = [ "serde_yml", ] +[[package]] +name = "openshell-providers" +version = "0.0.0" +dependencies = [ + "glob", + "openshell-core", + "serde", + "serde_json", + "serde_yml", + "thiserror", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index a8ff9106c..aae6f8c69 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -15,10 +15,12 @@ base64 = "0.22" jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } openshell-policy = { path = "../../crates/openshell-policy" } +openshell-providers = { path = "../../crates/openshell-providers" } prost-types = "0.14" rcgen = { version = "0.13", features = ["crypto", "pem"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" sha2 = "0.10" tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } tonic = { version = "0.14", features = ["transport"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 62fdfca6b..ed2291702 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -5,22 +5,31 @@ This standalone example implements the extend OpenShell to provide advanced governance over sandbox policies. - every new sandbox receives `policy.yaml` sourced from this examples folder -- every new sandbox is attached to exactly `github` and `gitlab` +- every new sandbox is attached to exactly `github` and `slack` +- `github` must use the `github` provider profile +- `slack` must use the custom `slack` provider profile +- governed provider network policy lives in `profiles/*.yaml`, not in the + signed baseline sandbox policy - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation that is used to verify the policy - every sandbox creation evaluation adds a `correlation_id` log annotation so the gateway log can be correlated with interceptor-side decisions - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation -- users cannot create provider records other than `github` and `gitlab` -- users cannot update or delete the governed `github` or `gitlab` provider records +- users cannot create provider records other than `github` and `slack` +- users cannot update or delete the governed `github` or `slack` provider records +- users cannot import or update provider profiles other than `github` and + `slack` +- provider profile deletion is blocked by the interceptor Run the interceptor: ```shell cargo run -- \ --listen 127.0.0.1:18081 \ - --policy policy.yaml + --policy policy.yaml \ + --profiles profiles \ + --gateway-endpoint http://127.0.0.1:8080 ``` At startup the example parses `policy.yaml`, converts it to the protobuf JSON @@ -30,6 +39,22 @@ sandbox under `metadata.annotations["openshell.nvidia.com/policy-signature"]` an verifies the JWT against the sandbox policy during the `CreateSandbox` validate phase. +Provider profile YAML files are loaded by the interceptor from `--profiles` +(default: this example's `profiles/` directory). The interceptor names each +profile from its filename without the extension: `profiles/github.yaml` becomes +profile ID `github`, and `profiles/slack.yaml` becomes profile ID `slack`. The +YAML files do not need an `id` field; if one is present, the filename still wins. + +When `--gateway-endpoint` is set, the interceptor reconciles the loaded profiles +through the gateway's normal provider profile APIs. GitHub is already a built-in +read-only profile, so the interceptor accepts the exported built-in `github` +profile as present; the gateway still rejects importing or updating that +built-in ID. Slack is a custom profile: the interceptor uses +`ImportProviderProfiles` for first-time vending and `UpdateProviderProfiles` for +ongoing changes. It exports the current profile to read `resource_version`, +injects that version into the loaded YAML payload, and submits +`UpdateProviderProfiles`. It never deletes governed profiles. + The signing key is generated in memory on each interceptor start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. @@ -45,7 +70,7 @@ Gateway TOML snippet: ```toml [[openshell.gateway.interceptors]] -name = "source-control-governance" +name = "provider-governance" grpc_endpoint = "http://127.0.0.1:18081" order = 10 failure_policy = "fail_closed" diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml index 5c668a442..407baef22 100644 --- a/examples/governance-interceptor/policy.yaml +++ b/examples/governance-interceptor/policy.yaml @@ -15,26 +15,4 @@ process: run_as_user: sandbox run_as_group: sandbox -network_policies: - github: - name: github-api-readonly - endpoints: - - host: api.github.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - binaries: - - { path: /usr/bin/git } - - { path: /usr/bin/curl } - gitlab: - name: gitlab-api-readonly - endpoints: - - host: gitlab.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - binaries: - - { path: /usr/bin/git } - - { path: /usr/bin/curl } +network_policies: {} diff --git a/examples/governance-interceptor/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml new file mode 100644 index 000000000..503967a53 --- /dev/null +++ b/examples/governance-interceptor/profiles/github.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: GitHub +description: GitHub API and Git operations +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/examples/governance-interceptor/profiles/slack.yaml b/examples/governance-interceptor/profiles/slack.yaml new file mode 100644 index 000000000..129691954 --- /dev/null +++ b/examples/governance-interceptor/profiles/slack.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: Slack +description: Read-only Slack Web API access for governed sandbox agents +category: messaging +credentials: + - name: api_token + description: Slack bot or user token + env_vars: [SLACK_BOT_TOKEN, SLACK_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /api/team.info + - allow: + method: GET + path: /api/users.info + - allow: + method: GET + path: /api/conversations.info + - allow: + method: GET + path: /api/conversations.history +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index b16342fbc..4cde6038d 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -7,66 +7,106 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" EXAMPLE_DIR="$ROOT/examples/governance-interceptor" TMPDIR="$(mktemp -d)" -SMOKE_RUN_ID="${OPENSHELL_GOVERNANCE_RUN_ID:-governance-smoke-$$-$RANDOM}" +LOG_DIR="$TMPDIR/logs" +JWT_DIR="$TMPDIR/jwt" +GATEWAY_CONFIG="$TMPDIR/gateway.toml" +SMOKE_LOG="$LOG_DIR/smoke.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" +RUN_ID="governance-smoke-$$-$RANDOM" +SANDBOX_NAME="$RUN_ID-sandbox" + +mkdir -p "$LOG_DIR" + +cleanup() { + local status=$? + trap - EXIT + + if [[ -n "${INTERCEPTOR_PID:-}" ]]; then + kill "$INTERCEPTOR_PID" 2>/dev/null || true + wait "$INTERCEPTOR_PID" 2>/dev/null || true + fi + + if [[ -n "${GATEWAY_PID:-}" ]]; then + kill "$GATEWAY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || true + fi + + if [[ "$status" -eq 0 ]]; then + rm -rf "$TMPDIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + + exit "$status" +} +trap cleanup EXIT + port_is_free() { local port="$1" + if command -v lsof >/dev/null 2>&1; then ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 - else + return + fi + + if command -v nc >/dev/null 2>&1; then ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + return fi + + return 0 } choose_port_block() { local count="$1" local start offset ok + for _ in {1..200}; do start=$((20000 + RANDOM % 20000)) ok=1 + for ((offset = 0; offset < count; offset++)); do if ! port_is_free "$((start + offset))"; then ok=0 break fi done + if [[ "$ok" == "1" ]]; then printf '%s\n' "$start" return fi done + echo "failed to find free local ports for smoke test" >&2 exit 1 } -DEFAULT_PORT_BASE="$(choose_port_block 3)" -JWT_DIR="$TMPDIR/jwt" -LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" -SMOKE_LOG="$LOG_DIR/smoke.log" -INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" -GATEWAY_LOG="$LOG_DIR/gateway.log" -INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:$DEFAULT_PORT_BASE}" -GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 1))}" -HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 2))}" -GATEWAY_ID="${OPENSHELL_GOVERNANCE_GATEWAY_ID:-$SMOKE_RUN_ID}" -SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-$SMOKE_RUN_ID-sandbox}" -mkdir -p "$LOG_DIR" +PORT_BASE="$(choose_port_block 3)" +INTERCEPTOR_ADDR="127.0.0.1:$PORT_BASE" +GATEWAY_PORT="$((PORT_BASE + 1))" +HEALTH_PORT="$((PORT_BASE + 2))" +GATEWAY_ADDR="127.0.0.1:$GATEWAY_PORT" +HEALTH_ADDR="127.0.0.1:$HEALTH_PORT" +GATEWAY_ENDPOINT="http://$GATEWAY_ADDR" -cleanup() { - status=$? - trap - EXIT - if [[ -n "${INTERCEPTOR_PID:-}" ]]; then kill "$INTERCEPTOR_PID" 2>/dev/null || true; fi - if [[ -n "${GATEWAY_PID:-}" ]]; then kill "$GATEWAY_PID" 2>/dev/null || true; fi - if [[ "$status" -eq 0 && "${OPENSHELL_GOVERNANCE_KEEP_LOGS:-0}" != "1" ]]; then - rm -rf "$TMPDIR" +dump_log_file() { + local label="$1" + local path="$2" + + printf '\n--- %s: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 else - echo "logs retained in $LOG_DIR" >&2 + printf '(missing)\n' >&2 fi - exit "$status" } -trap cleanup EXIT -log() { - printf '%s\n' "$*" >>"$SMOKE_LOG" +dump_logs() { + dump_log_file "smoke log" "$SMOKE_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" } pass() { @@ -79,51 +119,34 @@ fail() { exit 1 } -setup_fail() { - printf 'ERROR %s\n' "$1" >&2 - dump_logs - exit 1 -} - -dump_log_file() { +log_command() { local label="$1" - local path="$2" - printf '\n--- %s: %s ---\n' "$label" "$path" >&2 - if [[ -f "$path" ]]; then - cat "$path" >&2 - else - printf '(missing)\n' >&2 - fi -} + shift -dump_logs() { - dump_log_file "smoke log" "$SMOKE_LOG" - dump_log_file "gateway log" "$GATEWAY_LOG" - dump_log_file "interceptor log" "$INTERCEPTOR_LOG" + { + printf '\n== %s ==\n' "$label" + printf '+' + printf ' %q' "$@" + printf '\n' + } >>"$SMOKE_LOG" } run_setup_step() { local label="$1" shift + printf 'INFO %s\n' "$label" - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + log_command "$label" "$@" if ! "$@" >>"$SMOKE_LOG" 2>&1; then - setup_fail "$label" + fail "$label" fi } run_step() { local label="$1" shift - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + + log_command "$label" "$@" if "$@" >>"$SMOKE_LOG" 2>&1; then pass "$label" else @@ -134,11 +157,8 @@ run_step() { expect_failure() { local label="$1" shift - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + + log_command "$label" "$@" if "$@" >>"$SMOKE_LOG" 2>&1; then fail "$label" else @@ -151,12 +171,9 @@ expect_output_contains() { local needle="$2" shift 2 local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" - if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -q "$needle" "$output_file"; then + + log_command "$label" "$@" + if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -Fq -- "$needle" "$output_file"; then pass "$label" else cat "$output_file" >>"$SMOKE_LOG" 2>/dev/null || true @@ -168,81 +185,32 @@ expect_log_contains() { local label="$1" local needle="$2" local path="$3" - if grep -q "$needle" "$path"; then + + if grep -Fq -- "$needle" "$path"; then pass "$label" else fail "$label" fi } -configure_native_build_env() { - if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then - export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" - export CXX="${OPENSHELL_GOVERNANCE_CXX:-clang++}" - log "Using macOS native build compiler: CC=$CC CXX=$CXX" - fi - - if [[ "${OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER:-0}" != "1" ]]; then - export RUSTC_WRAPPER="${OPENSHELL_GOVERNANCE_RUSTC_WRAPPER:-}" - fi - - if [[ -z "${RUSTC_WRAPPER:-}" ]]; then - log "Building without RUSTC_WRAPPER for reproducible smoke builds." - else - log "Using RUSTC_WRAPPER=$RUSTC_WRAPPER" - fi -} - -docker_socket_responds() { - local socket="$1" - curl --silent --fail --unix-socket "$socket" http://localhost/_ping >/dev/null 2>&1 -} - -docker_context_socket() { - if ! command -v docker >/dev/null 2>&1; then - return - fi - - local endpoint - endpoint="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)" - if [[ "$endpoint" == unix://* ]]; then - printf '%s\n' "${endpoint#unix://}" - fi -} - -configure_container_runtime_env() { - if [[ -n "${DOCKER_HOST:-}" ]]; then - log "Using caller-provided DOCKER_HOST=$DOCKER_HOST" - return - fi - - local candidate - local -a candidates=() +wait_for_profile() { + local profile_id="$1" + local label="loads $profile_id provider profile" - candidate="$(docker_context_socket)" - if [[ -n "$candidate" ]]; then - candidates+=("$candidate") - fi - - if [[ -n "${HOME:-}" ]]; then - candidates+=( - "$HOME/.colima/default/docker.sock" - "$HOME/.colima/docker.sock" - "$HOME/.docker/run/docker.sock" - ) - fi - - candidates+=("/var/run/docker.sock") + { + printf '\n== %s ==\n' "$label" + printf '+ wait for provider profile %q\n' "$profile_id" + } >>"$SMOKE_LOG" - for candidate in "${candidates[@]}"; do - if [[ -S "$candidate" ]] && docker_socket_responds "$candidate"; then - export DOCKER_HOST="unix://$candidate" - log "Using Docker socket from $DOCKER_HOST for workspace builds and gateway runtime." + for _ in {1..60}; do + if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SMOKE_LOG" 2>&1; then + pass "$label" return fi + sleep 1 done - log "No reachable Docker socket detected; relying on gateway driver autodetection." + fail "$label" } generate_gateway_jwt_bundle() { @@ -254,65 +222,14 @@ generate_gateway_jwt_bundle() { mkdir -p "$JWT_DIR" openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 - printf '%s\n' "$GATEWAY_ID" > "$JWT_DIR/kid" -} - -start_dedicated_gateway() { - printf 'INFO starting dedicated gateway\n' - log "Starting dedicated gateway id=$GATEWAY_ID endpoint=http://$GATEWAY_ADDR health=http://$HEALTH_ADDR" - env \ - -u OPENSHELL_GATEWAY_CONFIG \ - -u OPENSHELL_BIND_ADDRESS \ - -u OPENSHELL_SERVER_PORT \ - -u OPENSHELL_HEALTH_PORT \ - -u OPENSHELL_METRICS_PORT \ - -u OPENSHELL_LOG_LEVEL \ - -u OPENSHELL_TLS_CERT \ - -u OPENSHELL_TLS_KEY \ - -u OPENSHELL_TLS_CLIENT_CA \ - -u OPENSHELL_LOCAL_TLS_DIR \ - -u OPENSHELL_DRIVERS \ - -u OPENSHELL_DISABLE_TLS \ - -u OPENSHELL_OIDC_ISSUER \ - -u OPENSHELL_ENABLE_MTLS_AUTH \ - -u OPENSHELL_OIDC_AUDIENCE \ - -u OPENSHELL_OIDC_JWKS_TTL \ - -u OPENSHELL_OIDC_ROLES_CLAIM \ - -u OPENSHELL_OIDC_ADMIN_ROLE \ - -u OPENSHELL_OIDC_USER_ROLE \ - -u OPENSHELL_OIDC_SCOPES_CLAIM \ - -u OPENSHELL_GRPC_RATE_LIMIT_REQUESTS \ - -u OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS \ - -u OPENSHELL_SERVER_SAN \ - -u OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP \ - "OPENSHELL_DB_URL=sqlite://$TMPDIR/gateway.db" \ - "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & - GATEWAY_PID=$! + printf '%s\n' "$RUN_ID" >"$JWT_DIR/kid" } -cd "$ROOT" -configure_native_build_env -configure_container_runtime_env -generate_gateway_jwt_bundle -run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway -run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell -run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" - -"$EXAMPLE_DIR/target/debug/governance-interceptor" \ - --listen "$INTERCEPTOR_ADDR" \ - --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & -INTERCEPTOR_PID=$! - -cat > "$TMPDIR/gateway.toml" <"$GATEWAY_CONFIG" <"$INTERCEPTOR_LOG" 2>&1 & + INTERCEPTOR_PID=$! +} -gateway_ready=0 -for _ in {1..60}; do - if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then - fail "gateway starts with interceptor" - fi - if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then - gateway_ready=1 - break - fi - sleep 1 -done -if [[ "$gateway_ready" == "1" ]]; then - pass "gateway starts with interceptor" -else - fail "gateway starts with interceptor" -fi +start_gateway() { + printf 'INFO starting gateway\n' + env -u OPENSHELL_DRIVERS "$ROOT/target/debug/openshell-gateway" \ + --config "$GATEWAY_CONFIG" \ + --bind-address 127.0.0.1 \ + --port "$GATEWAY_PORT" \ + --health-port "$HEALTH_PORT" \ + --metrics-port 0 \ + --log-level info \ + --disable-tls \ + --db-url "sqlite://$TMPDIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! +} + +wait_for_gateway() { + local label="gateway starts with interceptor" + + for _ in {1..60}; do + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "$label" + fi + + if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then + pass "$label" + return + fi + + sleep 1 + done + + fail "$label" +} + +run_suite() { + CLI=( + env + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "$GATEWAY_ENDPOINT" + ) + + run_step "enables provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes + wait_for_profile "github" + wait_for_profile "slack" + expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles + expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + + cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' +id: custom-slack +display_name: Custom Slack +description: Profile outside the managed github/slack set used to verify interceptor import denial +category: messaging +credentials: [] +endpoints: [] +binaries: [] +EOF -CLI=( - env - -u OPENSHELL_GATEWAY - -u OPENSHELL_GATEWAY_ENDPOINT - -u OPENSHELL_GATEWAY_INSECURE - -u OPENSHELL_SANDBOX_POLICY - "$ROOT/target/debug/openshell" - --gateway-endpoint "http://$GATEWAY_ADDR" -) + expect_failure "denies provider profile delete" "${CLI[@]}" provider profile delete slack + expect_failure "denies disallowed provider profile import" "${CLI[@]}" provider profile import -f "$TMPDIR/disallowed-profile.yaml" -run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy -run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy + expect_failure "denies slack provider with github profile" "${CLI[@]}" provider create --name slack --type github --credential SLACK_BOT_TOKEN=dummy + run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy + run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy -expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy + expect_failure "denies disallowed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy -run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true -expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" -expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" -expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" -expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true + expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" + expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" + expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_contains "sandbox has slack provider" "slack" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_contains "effective policy has github provider layer" "_provider_github" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + expect_output_contains "effective policy has slack provider layer" "_provider_slack" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json -expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" -expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" -expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + expect_failure "denies governed provider update" "${CLI[@]}" provider update slack --credential SLACK_BOT_TOKEN=changed + expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github +} -run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" +cd "$ROOT" -expect_failure "denies governed provider update" "${CLI[@]}" provider update gitlab --credential GITLAB_TOKEN=changed +run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building test CLI" cargo build --quiet -p openshell-cli --bin openshell -expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github +generate_gateway_jwt_bundle +write_gateway_config +start_interceptor +start_gateway +wait_for_gateway +run_suite echo "ALL PASS governance interceptor smoke" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 6e1e016e5..8120a883f 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use std::net::SocketAddr; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ @@ -16,16 +16,20 @@ use openshell_core::proto::gateway_interceptor::v1::{ gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; use openshell_core::proto::{ - GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, - SandboxPolicy, + GetProviderProfileRequest, GraphqlOperation, ImportProviderProfilesRequest, L7Allow, + L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, SandboxPolicy, + UpdateProviderProfilesRequest, open_shell_client::OpenShellClient, }; use openshell_policy::parse_sandbox_policy; +use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value, json}; use sha2::{Digest, Sha256}; -use tonic::transport::Server; +use tonic::Code; +use tonic::transport::{Channel, Server}; use tonic::{Request, Response, Status}; const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature"; @@ -34,7 +38,8 @@ const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; const POLICY_JWT_SUBJECT: &str = "policy.yaml"; const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; const SERVICE: &str = "openshell.v1.OpenShell"; -const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; +const PROFILE_RECONCILE_ATTEMPTS: usize = 60; +const PROFILE_RECONCILE_RETRY_DELAY: Duration = Duration::from_secs(1); #[derive(Clone)] struct PolicySigner { @@ -133,10 +138,20 @@ struct GovernanceInterceptorService { policy_hash: String, policy_signature: String, policy_signer: PolicySigner, + managed_profile_ids: Vec, +} + +#[derive(Clone, Debug)] +struct LoadedProviderProfile { + source: String, + profile: ProviderProfile, } impl GovernanceInterceptorService { - fn from_yaml(policy_yaml: &str) -> Result { + fn from_yaml(policy_yaml: &str, managed_profile_ids: Vec) -> Result { + if managed_profile_ids.is_empty() { + return Err("at least one provider profile must be loaded".to_string()); + } let policy = parse_sandbox_policy(policy_yaml) .map_err(|err| format!("failed to parse policy YAML: {err}"))?; let policy = sandbox_policy_to_proto_json(&policy); @@ -149,12 +164,13 @@ impl GovernanceInterceptorService { policy_hash, policy_signature, policy_signer, + managed_profile_ids, }) } fn manifest() -> InterceptorManifest { InterceptorManifest { - name: "source-control-governance".to_string(), + name: "provider-governance".to_string(), failure_policy: "fail_closed".to_string(), bindings: vec![ binding( @@ -195,6 +211,21 @@ impl GovernanceInterceptorService { "DeleteProvider", &[GatewayInterceptorPhase::Validate], ), + binding( + "govern-import-provider-profiles", + "ImportProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-provider-profiles", + "UpdateProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-delete-provider-profile", + "DeleteProviderProfile", + &[GatewayInterceptorPhase::Validate], + ), ], } } @@ -221,20 +252,27 @@ impl GovernanceInterceptorService { ( "AttachSandboxProvider" | "DetachSandboxProvider", GatewayInterceptorPhase::Validate, - ) => Ok(deny( - "source-control providers are fixed at sandbox creation", - )), + ) => Ok(deny("governed providers are fixed at sandbox creation")), ("UpdateConfig", GatewayInterceptorPhase::Validate) => { Ok(validate_update_config(&operation)) } ("CreateProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_create_provider(&operation)) + Ok(self.validate_create_provider(&operation)) } ("UpdateProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_update_provider(&operation)) + Ok(self.validate_update_provider(&operation)) } ("DeleteProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_delete_provider(&operation)) + Ok(self.validate_delete_provider(&operation)) + } + ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_import_provider_profiles(&operation)) + } + ("UpdateProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_update_provider_profiles(&operation)) + } + ("DeleteProviderProfile", GatewayInterceptorPhase::Validate) => { + Ok(validate_delete_provider_profile()) } _ => Ok(allow()), } @@ -247,7 +285,7 @@ impl GovernanceInterceptorService { patches.push(json_patch( "add", "/spec/providers", - json!(GOVERNED_PROVIDERS), + json!(&self.managed_profile_ids), )?); } else { patches.push(json_patch( @@ -255,7 +293,7 @@ impl GovernanceInterceptorService { "/spec", json!({ "policy": self.policy, - "providers": GOVERNED_PROVIDERS, + "providers": self.managed_profile_ids, }), )?); } @@ -280,7 +318,7 @@ impl GovernanceInterceptorService { fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { let Some(policy) = operation.pointer("/spec/policy") else { - return deny("sandbox policy must match the source-control governance baseline"); + return deny("sandbox policy must match the provider governance baseline"); }; let sandbox_policy_hash = match policy_hash(policy) { Ok(hash) => hash, @@ -302,13 +340,101 @@ impl GovernanceInterceptorService { return deny(&format!("sandbox policy signature is invalid: {err}")); } if sandbox_policy_hash != self.policy_hash || policy != &self.policy { - return deny("sandbox policy must match the source-control governance baseline"); + return deny("sandbox policy must match the provider governance baseline"); + } + if !providers_are_managed( + operation.pointer("/spec/providers"), + &self.managed_profile_ids, + ) { + return deny(&format!( + "sandbox providers must be exactly {}", + format_id_list(&self.managed_profile_ids) + )); + } + allow() + } + + fn validate_create_provider(&self, operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + let provider_type = provider_type(operation); + if !self.is_managed_profile_id(name) { + return deny(&format!( + "only managed provider records may be created: {}", + format_id_list(&self.managed_profile_ids) + )); + } + if provider_type != name { + return deny(&format!("provider '{name}' must use profile '{name}'")); + } + allow() + } + + fn validate_update_provider(&self, operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if self.is_managed_profile_id(name) { + deny("governed provider records cannot be updated") + } else { + allow() + } + } + + fn validate_delete_provider(&self, operation: &Value) -> InterceptorResult { + let name = operation + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + if self.is_managed_profile_id(name) { + deny("governed provider records cannot be deleted") + } else { + allow() + } + } + + fn validate_import_provider_profiles(&self, operation: &Value) -> InterceptorResult { + let Some(profiles) = operation.get("profiles").and_then(Value::as_array) else { + return deny("provider profile imports must include governed profile payloads"); + }; + if profiles.is_empty() { + return deny("provider profile imports must include governed profile payloads"); + } + for item in profiles { + let id = profile_id_from_import_item(item); + if !self.is_managed_profile_id(id) { + return deny(&format!( + "only managed provider profiles may be imported: {}", + format_id_list(&self.managed_profile_ids) + )); + } + } + allow() + } + + fn validate_update_provider_profiles(&self, operation: &Value) -> InterceptorResult { + let target_id = operation + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + if !self.is_managed_profile_id(target_id) { + return deny(&format!( + "only managed provider profiles may be updated: {}", + format_id_list(&self.managed_profile_ids) + )); } - if !providers_are_governed(operation.pointer("/spec/providers")) { - return deny("sandbox providers must be exactly github and gitlab"); + let payload_id = operation + .get("profile") + .map(profile_id_from_import_item) + .unwrap_or_default(); + if payload_id != target_id { + return deny( + "provider profile update target must match the governed profile payload id", + ); } allow() } + + fn is_managed_profile_id(&self, id: &str) -> bool { + self.managed_profile_ids.iter().any(|managed| managed == id) + } } #[tonic::async_trait] @@ -387,58 +513,284 @@ fn validate_update_config(operation: &Value) -> InterceptorResult { } } -fn validate_create_provider(operation: &Value) -> InterceptorResult { - let name = provider_name(operation); - if is_governed_provider(name) { - allow() - } else { - deny("only github and gitlab provider records may be created") - } +fn validate_delete_provider_profile() -> InterceptorResult { + deny("provider profile deletes are blocked by provider governance") } -fn validate_update_provider(operation: &Value) -> InterceptorResult { - let name = provider_name(operation); - if is_governed_provider(name) { - deny("governed provider records cannot be updated") - } else { - allow() - } +fn provider_name(operation: &Value) -> &str { + operation + .pointer("/provider/metadata/name") + .and_then(Value::as_str) + .unwrap_or_default() } -fn validate_delete_provider(operation: &Value) -> InterceptorResult { - let name = operation - .get("name") +fn provider_type(operation: &Value) -> &str { + operation + .pointer("/provider/type") .and_then(Value::as_str) - .unwrap_or_default(); - if is_governed_provider(name) { - deny("governed provider records cannot be deleted") - } else { - allow() - } + .unwrap_or_default() } -fn provider_name(operation: &Value) -> &str { - operation - .pointer("/provider/metadata/name") +fn profile_id_from_import_item(item: &Value) -> &str { + item.pointer("/profile/id") .and_then(Value::as_str) .unwrap_or_default() } -fn is_governed_provider(name: &str) -> bool { - GOVERNED_PROVIDERS.contains(&name) +fn load_provider_profiles(path: &Path) -> Result, String> { + if path.is_dir() { + let mut entries = std::fs::read_dir(path) + .map_err(|err| format!("failed to read provider profiles dir: {err}"))? + .collect::, _>>() + .map_err(|err| format!("failed to read provider profiles dir entry: {err}"))?; + entries.sort_by_key(|entry| entry.path()); + let mut profiles = Vec::new(); + for entry in entries { + let path = entry.path(); + if !profile_path_supported(&path) { + continue; + } + profiles.push(load_provider_profile_file(&path)?); + } + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + if path.is_file() { + let profiles = vec![load_provider_profile_file(path)?]; + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + Err(format!( + "provider profiles path not found: {}", + path.display() + )) +} + +fn load_provider_profile_file(path: &Path) -> Result { + let profile_id = profile_id_from_file_name(path)?; + let input = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read provider profile {}: {err}", path.display()))?; + let source = path.display().to_string(); + load_provider_profile_source(&source, &input, &profile_id) +} + +fn load_provider_profile_source( + source: &str, + input: &str, + profile_id: &str, +) -> Result { + let mut value = serde_yml::from_str::(input) + .map_err(|err| format!("failed to parse provider profile {source}: {err}"))?; + let mapping = value + .as_mapping_mut() + .ok_or_else(|| format!("provider profile {source} must be a YAML mapping"))?; + mapping.insert( + serde_yml::Value::String("id".to_string()), + serde_yml::Value::String(profile_id.to_string()), + ); + let profile = serde_yml::from_value::(value) + .map_err(|err| format!("failed to decode provider profile {source}: {err}"))? + .to_proto(); + Ok(LoadedProviderProfile { + source: source.to_string(), + profile, + }) +} + +fn profile_id_from_file_name(path: &Path) -> Result { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| { + format!( + "provider profile path has no UTF-8 file stem: {}", + path.display() + ) + })?; + let Some(normalized) = normalize_profile_id(stem) else { + return Err(format!( + "provider profile filename stem must be lowercase kebab-case: {}", + path.display() + )); + }; + if normalized != stem { + return Err(format!( + "provider profile filename stem must already be normalized: {}", + path.display() + )); + } + Ok(normalized) +} + +fn profile_path_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml") + ) +} + +fn validate_loaded_profiles(profiles: &[LoadedProviderProfile]) -> Result<(), String> { + if profiles.is_empty() { + return Err("provider profiles path did not contain any YAML files".to_string()); + } + let mut ids = profiles + .iter() + .map(|profile| profile.profile.id.as_str()) + .collect::>(); + ids.sort_unstable(); + for pair in ids.windows(2) { + if pair[0] == pair[1] { + return Err(format!( + "duplicate provider profile filename stem: {}", + pair[0] + )); + } + } + Ok(()) +} + +fn loaded_profile_ids(profiles: &[LoadedProviderProfile]) -> Vec { + profiles + .iter() + .map(|profile| profile.profile.id.clone()) + .collect() +} + +fn format_id_list(ids: &[String]) -> String { + ids.join(", ") +} + +fn spawn_profile_reconciler(gateway_endpoint: String, profiles: Vec) { + tokio::spawn(async move { + let mut last_error = String::new(); + for attempt in 1..=PROFILE_RECONCILE_ATTEMPTS { + match reconcile_provider_profiles(&gateway_endpoint, &profiles).await { + Ok(()) => { + println!("provider profiles reconciled with gateway {gateway_endpoint}"); + return; + } + Err(err) => { + last_error = err; + eprintln!( + "provider profile reconcile attempt {attempt}/{PROFILE_RECONCILE_ATTEMPTS} failed: {last_error}" + ); + tokio::time::sleep(PROFILE_RECONCILE_RETRY_DELAY).await; + } + } + } + eprintln!( + "provider profile reconcile failed after {PROFILE_RECONCILE_ATTEMPTS} attempts: {last_error}" + ); + }); +} + +async fn reconcile_provider_profiles( + gateway_endpoint: &str, + profiles: &[LoadedProviderProfile], +) -> Result<(), String> { + let mut client = OpenShellClient::connect(gateway_endpoint.to_string()) + .await + .map_err(|err| format!("connect gateway: {err}"))?; + for loaded in profiles { + reconcile_provider_profile(&mut client, loaded).await?; + } + Ok(()) +} + +async fn reconcile_provider_profile( + client: &mut OpenShellClient, + loaded: &LoadedProviderProfile, +) -> Result<(), String> { + let id = loaded.profile.id.clone(); + match client + .get_provider_profile(GetProviderProfileRequest { id: id.clone() }) + .await + { + Ok(response) => { + let current = response + .into_inner() + .profile + .ok_or_else(|| format!("provider profile '{id}' missing from get response"))?; + if current.resource_version == 0 { + println!("provider profile '{id}' is built in; using gateway copy"); + return Ok(()); + } + let mut profile = loaded.profile.clone(); + profile.resource_version = current.resource_version; + let response = client + .update_provider_profiles(UpdateProviderProfilesRequest { + id: id.clone(), + expected_resource_version: profile.resource_version, + profile: Some(ProviderProfileImportItem { + source: loaded.source.clone(), + profile: Some(profile), + }), + }) + .await + .map_err(|status| format!("update provider profile '{id}': {status}"))? + .into_inner(); + if response.updated { + println!("updated provider profile '{id}'"); + Ok(()) + } else { + Err(format!( + "update provider profile '{id}' rejected: {}", + format_profile_diagnostics(&response.diagnostics) + )) + } + } + Err(status) if status.code() == Code::NotFound => { + let response = client + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + source: loaded.source.clone(), + profile: Some(loaded.profile.clone()), + }], + }) + .await + .map_err(|status| format!("import provider profile '{id}': {status}"))? + .into_inner(); + if response.imported { + println!("imported provider profile '{id}'"); + Ok(()) + } else { + Err(format!( + "import provider profile '{id}' rejected: {}", + format_profile_diagnostics(&response.diagnostics) + )) + } + } + Err(status) => Err(format!("get provider profile '{id}': {status}")), + } +} + +fn format_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) -> String { + if diagnostics.is_empty() { + return "no diagnostics returned".to_string(); + } + diagnostics + .iter() + .map(|diagnostic| { + format!( + "{}:{}:{}:{}", + diagnostic.source, diagnostic.profile_id, diagnostic.field, diagnostic.message + ) + }) + .collect::>() + .join("; ") } -fn providers_are_governed(value: Option<&Value>) -> bool { +fn providers_are_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { let Some(Value::Array(providers)) = value else { return false; }; - if providers.len() != GOVERNED_PROVIDERS.len() { + if providers.len() != managed_profile_ids.len() { return false; } - GOVERNED_PROVIDERS.iter().all(|provider| { + managed_profile_ids.iter().all(|provider| { providers .iter() - .any(|value| value.as_str() == Some(provider)) + .any(|value| value.as_str() == Some(provider.as_str())) }) } @@ -804,6 +1156,8 @@ fn proto_value_to_json(value: &ProtoValue) -> Value { async fn main() -> Result<(), Box> { let mut listen: SocketAddr = "127.0.0.1:18081".parse()?; let mut policy_path: Option = None; + let mut profiles_path: Option = None; + let mut gateway_endpoint: Option = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { @@ -815,8 +1169,18 @@ async fn main() -> Result<(), Box> { let value = args.next().ok_or("--policy requires a path")?; policy_path = Some(PathBuf::from(value)); } + "--profiles" => { + let value = args.next().ok_or("--profiles requires a path")?; + profiles_path = Some(PathBuf::from(value)); + } + "--gateway-endpoint" => { + let value = args.next().ok_or("--gateway-endpoint requires a URL")?; + gateway_endpoint = Some(value); + } "-h" | "--help" => { - println!("usage: governance-interceptor [--listen ADDR] [--policy FILE]"); + println!( + "usage: governance-interceptor [--listen ADDR] [--policy FILE] [--profiles FILE_OR_DIR] [--gateway-endpoint URL]" + ); return Ok(()); } _ => return Err(format!("unknown argument: {arg}").into()), @@ -828,7 +1192,23 @@ async fn main() -> Result<(), Box> { } else { include_str!("../policy.yaml").to_string() }; - let service = GovernanceInterceptorService::from_yaml(&policy_yaml)?; + let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); + let profiles = load_provider_profiles(&profiles_path)?; + let profile_ids = loaded_profile_ids(&profiles); + let service = GovernanceInterceptorService::from_yaml(&policy_yaml, profile_ids)?; + + if let Some(endpoint) = gateway_endpoint { + spawn_profile_reconciler(endpoint, profiles); + } else { + println!( + "loaded provider profiles: {}", + profiles + .iter() + .map(|profile| profile.profile.id.as_str()) + .collect::>() + .join(", ") + ); + } println!("governance interceptor listening on {listen}"); Server::builder() @@ -838,12 +1218,21 @@ async fn main() -> Result<(), Box> { Ok(()) } +fn default_profiles_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles") +} + #[cfg(test)] mod tests { use super::*; fn service() -> GovernanceInterceptorService { - GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml")).unwrap() + let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); + GovernanceInterceptorService::from_yaml( + include_str!("../policy.yaml"), + loaded_profile_ids(&profiles), + ) + .unwrap() } fn evaluation( @@ -863,11 +1252,15 @@ mod tests { } } - fn governed_create_operation(policy: Value, signature: String) -> Value { + fn governed_create_operation( + service: &GovernanceInterceptorService, + policy: Value, + signature: String, + ) -> Value { let mut operation = json!({ "spec": { "policy": policy, - "providers": GOVERNED_PROVIDERS, + "providers": &service.managed_profile_ids, }, "annotations": {}, }); @@ -883,7 +1276,11 @@ mod tests { } fn valid_create_operation(service: &GovernanceInterceptorService) -> Value { - governed_create_operation(service.policy.clone(), service.policy_signature.clone()) + governed_create_operation( + service, + service.policy.clone(), + service.policy_signature.clone(), + ) } fn signature_patch_token(result: &InterceptorResult) -> String { @@ -921,9 +1318,44 @@ mod tests { assert!(ids.contains(&"govern-create-sandbox")); assert!(ids.contains(&"govern-attach-provider")); assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-import-provider-profiles")); + assert!(ids.contains(&"govern-update-provider-profiles")); + assert!(ids.contains(&"govern-delete-provider-profile")); assert_eq!(manifest.failure_policy, "fail_closed"); } + #[test] + fn profile_loader_uses_file_name_as_profile_id() { + let loaded = load_provider_profile_source( + "profiles/example-api.yaml", + r#" +id: ignored +display_name: Example API +description: Example profile +credentials: [] +endpoints: [] +binaries: [] +"#, + "example-api", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "example-api"); + + let loaded = load_provider_profile_source( + "profiles/no-id.yaml", + r#" +display_name: No ID +description: Filename supplies the profile id +credentials: [] +endpoints: [] +binaries: [] +"#, + "no-id", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "no-id"); + } + #[test] fn create_sandbox_modify_adds_policy_providers_and_signature() { let service = service(); @@ -983,7 +1415,7 @@ mod tests { json!({ "spec": { "policy": service.policy, - "providers": GOVERNED_PROVIDERS, + "providers": service.managed_profile_ids, }, }), )) @@ -999,7 +1431,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(service.policy.clone(), "not-a-jwt".to_string()), + governed_create_operation( + &service, + service.policy.clone(), + "not-a-jwt".to_string(), + ), )) .unwrap(); assert!(!result.allowed); @@ -1014,7 +1450,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(governance.policy.clone(), other.policy_signature), + governed_create_operation( + &governance, + governance.policy.clone(), + other.policy_signature, + ), )) .unwrap(); assert!(!result.allowed); @@ -1033,7 +1473,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(tampered_policy, service.policy_signature.clone()), + governed_create_operation( + &service, + tampered_policy, + service.policy_signature.clone(), + ), )) .unwrap(); assert!(!result.allowed); @@ -1056,7 +1500,7 @@ mod tests { .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "github"}}}), + json!({"provider": {"metadata": {"name": "github"}, "type": "github"}}), )) .unwrap(); assert!(github.allowed); @@ -1065,10 +1509,110 @@ mod tests { .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "slack"}}}), + json!({"provider": {"metadata": {"name": "slack"}, "type": "slack"}}), + )) + .unwrap(); + assert!(slack.allowed); + + let wrong_profile = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}, "type": "github"}}), + )) + .unwrap(); + assert!(!wrong_profile.allowed); + assert!(wrong_profile.reason.contains("slack")); + + let teams = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}), + )) + .unwrap(); + assert!(!teams.allowed); + } + + #[test] + fn provider_profile_import_is_limited_to_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "profiles": [ + {"profile": {"id": "github"}}, + {"profile": {"id": "slack"}} + ] + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({"profiles": [{"profile": {"id": "custom-slack"}}]}), + )) + .unwrap(); + assert!(!result.allowed); + } + + #[test] + fn provider_profile_update_is_limited_to_matching_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "slack"}} + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "github"}} + }), + )) + .unwrap(); + assert!(!result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "custom-slack", + "profile": {"profile": {"id": "custom-slack"}} + }), )) .unwrap(); - assert!(!slack.allowed); + assert!(!result.allowed); + } + + #[test] + fn provider_profile_delete_is_denied() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "DeleteProviderProfile", + GatewayInterceptorPhase::Validate, + json!({"id": "github"}), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("deletes are blocked")); } #[test] @@ -1111,7 +1655,7 @@ mod tests { .evaluate_inner(&evaluation( "UpdateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "gitlab"}}}), + json!({"provider": {"metadata": {"name": "slack"}}}), )) .unwrap(); assert!(!update.allowed);