From deb126d41db1192e3d681a9de4fb129250b067d2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 14:08:34 -0700 Subject: [PATCH 01/27] 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 34b24bf768..342e604478 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 76dedf0625..16756b3a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3753,6 +3753,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" @@ -3881,6 +3900,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 d873b2a105..ceaf338aef 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 a0f4ec9cf5..2239d23585 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -384,6 +384,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; @@ -547,6 +550,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 } @@ -607,6 +687,7 @@ impl Config { tls, oidc: None, auth: GatewayAuthConfig::default(), + gateway_interceptors: Vec::new(), mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, database_url: String::new(), @@ -697,6 +778,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 3212963691..8830bce9ad 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 08b062d2e5..2cd82ff764 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 0000000000..f38b055bb3 --- /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 0000000000..2460c499b3 --- /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 0000000000..6ddaa08a95 --- /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 b5c9b34d71..c4fe7e8d0b 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 9aee2bc6d6..0df81504a8 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -372,6 +372,11 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result, #[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 6462ccbbf3..1796f7840a 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -147,6 +147,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 { @@ -197,6 +201,7 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + gateway_interceptors: None, } } } @@ -263,6 +268,12 @@ pub(crate) 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(), @@ -273,6 +284,7 @@ pub(crate) 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 9e70c64721..49680ebf19 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, TokioTimer}, @@ -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 { @@ -223,6 +228,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, @@ -963,7 +1126,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> { @@ -995,7 +1158,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> { @@ -1376,7 +1539,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 247774a6c0..af0a04d43a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -133,6 +133,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. @@ -141,6 +159,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 0000000000..6a8a158209 --- /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 0000000000..ada1f5ecf9 --- /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 0000000000..c2f93a02ec --- /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 0000000000..5c668a442b --- /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 0000000000..52681f9783 --- /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 0000000000..f9d2c89753 --- /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 0000000000..ec7164ab1d --- /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 dea60bed18fc88545afa4f63af7cceb7be0277b7 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:36:04 -0700 Subject: [PATCH 02/27] 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 | 75 ++++ 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, 843 insertions(+), 93 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..12f1a80051 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1928,6 +1928,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, + annotations: HashMap::new(), }; let response = match client.create_sandbox(request).await { @@ -3427,10 +3428,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()), @@ -3883,6 +3889,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(), @@ -3925,6 +3932,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(), @@ -4805,6 +4813,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, @@ -5751,6 +5760,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, @@ -9724,6 +9734,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 7bf8612b4a..24ab5e4bb7 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 53b178acbb..3987d292cf 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -129,6 +129,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + annotations: HashMap::new(), }), spec: None, status: None, @@ -604,6 +605,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 ec8bd53743..3dec8943f3 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 8e799f821e..d8f8e695f2 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 2239d23585..520e663308 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -621,7 +621,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 2460c499b3..c310dee189 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 3a92cd209a..683ef8e872 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1159,6 +1159,7 @@ impl ComputeRuntime { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status, @@ -2386,6 +2387,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() }; @@ -2401,6 +2403,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}"), @@ -3546,6 +3549,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 88c771bed5..944b9b3ed7 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 fe2eb331c9..a71274e2d0 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 c016531eb6..b9af57f895 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4091,6 +4091,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4124,6 +4125,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4156,6 +4158,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4191,6 +4194,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4278,6 +4282,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4358,6 +4363,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, @@ -4384,6 +4390,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())) @@ -4427,6 +4434,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4498,6 +4506,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(), @@ -4542,6 +4551,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(), @@ -4607,6 +4617,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(), @@ -4873,6 +4884,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(), @@ -5157,6 +5169,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(), @@ -5558,6 +5571,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), @@ -5647,6 +5661,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, @@ -5751,6 +5766,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, @@ -5966,6 +5982,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, @@ -6062,6 +6079,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 { @@ -6177,6 +6195,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 { @@ -6380,6 +6399,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 { @@ -6477,6 +6497,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 { @@ -6582,6 +6603,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 { @@ -6675,6 +6697,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 { @@ -6760,6 +6783,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 { @@ -6847,6 +6871,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 { @@ -6934,6 +6959,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 { @@ -7026,6 +7052,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 { @@ -7200,6 +7227,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 { @@ -7296,6 +7324,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 { @@ -7381,6 +7410,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 { @@ -7475,6 +7505,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(), @@ -7513,6 +7544,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7635,6 +7667,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 { @@ -7821,6 +7854,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, @@ -7935,6 +7969,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, @@ -8298,6 +8333,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, @@ -8412,6 +8448,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, @@ -8428,6 +8465,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, @@ -10037,6 +10075,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 @@ -10246,6 +10285,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10327,6 +10367,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 d5a5f5c909..a139205181 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 542fee97c0..8e2f6d25c5 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 @@ -182,6 +183,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, @@ -1361,6 +1363,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(), @@ -2306,6 +2309,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())) @@ -2323,6 +2327,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(), @@ -2693,6 +2698,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2725,6 +2731,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2735,6 +2742,73 @@ mod tests { assert!(err.message().contains("reserved '_provider_' prefix")); } + #[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; @@ -2756,6 +2830,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 246d639bef..01d8dbfe8e 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 d8c02e7500..8f4242af17 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, }; // --------------------------------------------------------------------------- @@ -263,6 +263,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"]; @@ -622,6 +644,11 @@ pub(super) fn validate_object_metadata( validate_label_value(value)?; } + validate_annotations( + &metadata.annotations, + &format!("{resource_type}.metadata.annotations"), + )?; + Ok(()) } @@ -1153,6 +1180,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 43416c35d1..2b58bd15cd 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 d092b68de0..91a24f3733 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 b0b9a927c4..b604a3ef33 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 7ebd6dba93..1dabb77442 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 752fee1c08..4c6589b9f7 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 4adf9e8b6f..16ddbc0d47 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 b6a8dafaf2..4350890f31 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1379,6 +1379,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }; let sandbox_name = @@ -1643,6 +1644,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(), @@ -1735,6 +1737,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 af0a04d43a..a473d0ad90 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -145,7 +145,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 6a8a158209..6ca15e7f68 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 ada1f5ecf9..a8ff9106c9 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 c2f93a02ec..0f400a95c4 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 52681f9783..ff967fdcf5 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 f92d7b7a36..cfd6e63c45 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 ec7164ab1d..efa8bc88a2 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 d2d884f2e8..5bba9560fa 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 d6a6c200d2c09ffa4a57bbf1cdbb8240c40c8dda Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:38:51 -0700 Subject: [PATCH 03/27] 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 0f400a95c4..20e52f3d4c 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 f6ba75fd1dccd0c2d99166a254a08d26beee3e99 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 18:57:12 -0700 Subject: [PATCH 04/27] 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 c310dee189..1b7b0e7b18 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 49680ebf19..ee7cfaab5f 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, TokioTimer}, @@ -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)] @@ -278,14 +279,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 { @@ -312,6 +308,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 {} }) @@ -1049,6 +1063,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 6d7193a26ce705c1070b88b1085b86cbba37d7a0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 25 Jun 2026 09:10:10 -0700 Subject: [PATCH 05/27] 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 1b7b0e7b18..c8d82566f2 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 20e52f3d4c..c4ac7f1d35 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 ff967fdcf5..3ce6b6cc2e 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 041cd16c53871c00dddfe8451bfb6eb0a417f99c Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 09:02:18 -0700 Subject: [PATCH 06/27] 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 520e663308..691176be4c 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -587,7 +587,6 @@ pub struct GatewayInterceptorConfig { pub enum GatewayInterceptorFailurePolicy { FailClosed, FailOpen, - Ignore, } /// Configured override for a manifest binding. @@ -911,9 +910,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, normalize_compute_driver_name, - 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, + normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -991,6 +990,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 c8d82566f2..004c5a9e37 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 a473d0ad90..07f6025316 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -161,7 +161,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 efa8bc88a2..28e6785b32 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 d82d46d6b76d9c955ed0f9f97deca97c811427d1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 11:07:57 -0700 Subject: [PATCH 07/27] 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 16756b3a12..507693cadb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3770,6 +3770,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 f38b055bb3..7800d6628e 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 004c5a9e37..73eb4355ad 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 c4ac7f1d35..62fdfca6b6 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 3ce6b6cc2e..b16342fbca 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 c2a862ebe5..6e1e016e5d 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 28e6785b32..26f3a411c3 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 e948fddcd67b33a0a52b69e2c1863c6521461b60 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 17:04:50 -0700 Subject: [PATCH 08/27] 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 6ca15e7f68..137db87eb6 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 a8ff9106c9..aae6f8c698 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 62fdfca6b6..ed22917021 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 5c668a442b..407baef228 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 0000000000..503967a532 --- /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 0000000000..1296919548 --- /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 b16342fbca..4cde6038d7 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 6e1e016e5d..8120a883fa 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); From 7ced09733a709c3008e2d66384cb7a797a036601 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 29 Jun 2026 11:17:46 -0700 Subject: [PATCH 09/27] fix(gateway): preserve update config annotations Signed-off-by: Drew Newberry --- architecture/gateway.md | 2 +- crates/openshell-cli/src/run.rs | 59 +- crates/openshell-core/src/grpc_client.rs | 7 +- .../openshell-gateway-interceptors/src/lib.rs | 31 +- crates/openshell-server/src/grpc/policy.rs | 643 ++++++++++++++++-- crates/openshell-tui/src/app.rs | 9 + crates/openshell-tui/src/lib.rs | 32 +- crates/openshell-tui/src/ui/sandbox_detail.rs | 21 +- proto/openshell.proto | 5 + 9 files changed, 688 insertions(+), 121 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index ceaf338aef..6023492b13 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -231,7 +231,7 @@ modes: write. Client-facing operations that carry an `expected_resource_version` field use this mode: `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateProvider`, `UpdateProviderProfiles`, and `UpdateConfig` (policy - backfill path). + backfill and sandbox annotation updates). **Lists.** The `list_messages` and `list_messages_with_selector` helpers decode protobuf payloads from list results and hydrate `resource_version` from the diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 12f1a80051..516e0449ef 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1971,13 +1971,9 @@ pub async fn sandbox_create( match client .update_config(UpdateConfigRequest { name: sandbox_name.clone(), - policy: None, setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await { @@ -2684,6 +2680,17 @@ pub async fn sandbox_get( } } + if let Some(metadata) = &sandbox.metadata + && !metadata.annotations.is_empty() + { + println!(" {} ", "Annotations:".dimmed()); + let mut annotations: Vec<_> = metadata.annotations.iter().collect(); + annotations.sort_by_key(|(k, _)| *k); + for (key, value) in annotations { + println!(" {key}: {value}"); + } + } + let policy_from_global = config.policy_source == PolicySource::Global as i32; println!( " {} {}", @@ -6372,12 +6379,8 @@ pub async fn sandbox_policy_set_global( .update_config(UpdateConfigRequest { name: String::new(), policy: Some(policy), - setting_key: String::new(), - setting_value: None, - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6570,13 +6573,10 @@ pub async fn gateway_setting_set( let response = client .update_config(UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key.to_string(), setting_value: Some(setting_value), - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6605,13 +6605,9 @@ pub async fn sandbox_setting_set( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, setting_key: key.to_string(), setting_value: Some(setting_value), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6640,13 +6636,10 @@ pub async fn gateway_setting_delete( let response = client .update_config(UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key.to_string(), - setting_value: None, delete_setting: true, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6675,13 +6668,9 @@ pub async fn sandbox_setting_delete( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, setting_key: key.to_string(), - setting_value: None, delete_setting: true, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6735,12 +6724,7 @@ pub async fn sandbox_policy_set( .update_config(UpdateConfigRequest { name: name.to_string(), policy: Some(policy), - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()?; @@ -6909,13 +6893,8 @@ pub async fn sandbox_policy_update( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, merge_operations: plan.merge_operations, - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 92eab00408..d5d6ed9552 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -632,12 +632,7 @@ async fn sync_policy_with_client( .update_config(UpdateConfigRequest { name: sandbox.to_string(), policy: Some(policy.clone()), - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic() diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 73eb4355ad..3b2b9492cc 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -1945,7 +1945,9 @@ fn json_u32(value: &Value, field: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::{CreateSandboxRequest, SandboxSpec, SandboxTemplate}; + use openshell_core::proto::{ + CreateSandboxRequest, SandboxSpec, SandboxTemplate, UpdateConfigRequest, + }; use serde_json::json; use std::sync::{Arc, Mutex}; use tracing_subscriber::layer::SubscriberExt; @@ -2081,6 +2083,33 @@ mod tests { assert_eq!(decoded, request); } + #[test] + fn dynamic_update_config_round_trip_preserves_annotations() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = UpdateConfigRequest { + name: "demo".to_string(), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.UpdateConfigRequest", &bytes) + .unwrap(); + assert_eq!( + json["annotations"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + let encoded = descriptors + .encode_json_to_message("openshell.v1.UpdateConfigRequest", &json) + .unwrap(); + let decoded = UpdateConfigRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + #[test] fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { let descriptors = diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index b9af57f895..9059306f7a 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -71,7 +71,7 @@ use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; use super::validation::{ - level_matches, source_matches, validate_no_reserved_provider_policy_keys, + level_matches, source_matches, validate_annotations, validate_no_reserved_provider_policy_keys, validate_policy_safety, validate_static_fields_unchanged, }; use super::{MAX_PAGE_SIZE, StoredSettingValue, StoredSettings, clamp_limit}; @@ -1077,6 +1077,61 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu Ok(()) } +fn sandbox_metadata_annotations(sandbox: &Sandbox) -> HashMap { + sandbox + .metadata + .as_ref() + .map(|metadata| metadata.annotations.clone()) + .unwrap_or_default() +} + +fn update_config_response( + version: u32, + policy_hash: impl Into, + settings_revision: u64, + deleted: bool, + annotations: HashMap, +) -> Response { + Response::new(UpdateConfigResponse { + version, + policy_hash: policy_hash.into(), + settings_revision, + deleted, + annotations, + }) +} + +async fn persist_update_config_annotations( + state: &Arc, + sandbox_id: &str, + expected_resource_version: u64, + annotations: &HashMap, + current_annotations: &HashMap, +) -> Result, Status> { + if annotations.is_empty() { + return Ok(current_annotations.clone()); + } + if annotations + .iter() + .all(|(key, value)| current_annotations.get(key) == Some(value)) + { + return Ok(current_annotations.clone()); + } + + let annotations = annotations.clone(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.annotations.extend(annotations.clone()); + } + }) + .await + .map_err(|e| super::persistence_error_to_status(e, "store update annotations"))?; + + Ok(sandbox_metadata_annotations(&updated)) +} + async fn resolve_sandbox_by_name_for_principal( store: &Store, principal: &Principal, @@ -1486,6 +1541,7 @@ async fn handle_update_config_inner( sandbox_caller: bool, ) -> Result, Status> { let req = request.into_inner(); + validate_annotations(&req.annotations, "annotations")?; if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( @@ -1518,6 +1574,11 @@ async fn handle_update_config_inner( } if req.global { + if !req.annotations.is_empty() { + return Err(Status::invalid_argument( + "annotations are only supported for sandbox-scoped updates", + )); + } let _settings_guard = state.settings_mutex.lock().await; if has_merge_ops { @@ -1563,12 +1624,13 @@ async fn handle_update_config_inner( global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(current.version).unwrap_or(0), - policy_hash: hash, - settings_revision: global_settings.revision, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(current.version).unwrap_or(0), + hash, + global_settings.revision, + false, + HashMap::new(), + )); } let next_version = latest.map_or(1, |r| r.version + 1); @@ -1618,12 +1680,13 @@ async fn handle_update_config_inner( save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(next_version).unwrap_or(0), - policy_hash: hash, - settings_revision: global_settings.revision, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(next_version).unwrap_or(0), + hash, + global_settings.revision, + false, + HashMap::new(), + )); } // Global setting mutation. @@ -1666,12 +1729,13 @@ async fn handle_update_config_inner( save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: global_settings.revision, - deleted: req.delete_setting && changed, - })); + return Ok(update_config_response( + 0, + String::new(), + global_settings.revision, + req.delete_setting && changed, + HashMap::new(), + )); } if req.name.is_empty() { @@ -1688,6 +1752,7 @@ async fn handle_update_config_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + let mut response_annotations = sandbox_metadata_annotations(&sandbox); if has_setting { let _settings_guard = state.settings_mutex.lock().await; @@ -1721,12 +1786,22 @@ async fn handle_update_config_inner( .await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: sandbox_settings.revision, - deleted: removed, - })); + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + + return Ok(update_config_response( + 0, + String::new(), + sandbox_settings.revision, + removed, + response_annotations, + )); } if globally_managed { @@ -1754,12 +1829,22 @@ async fn handle_update_config_inner( .await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: sandbox_settings.revision, - deleted: false, - })); + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + + return Ok(update_config_response( + 0, + String::new(), + sandbox_settings.revision, + false, + response_annotations, + )); } if has_merge_ops { @@ -1783,6 +1868,14 @@ async fn handle_update_config_inner( &merge_ops, ) .await?; + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; state.sandbox_watch_bus.notify(&sandbox_id); emit_gateway_policy_audit_log( @@ -1818,12 +1911,13 @@ async fn handle_update_config_inner( ); emit_config_update_policy_success(sandbox_caller); - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )); } // Sandbox-scoped policy update. @@ -1863,7 +1957,8 @@ async fn handle_update_config_inner( let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let sandbox_id = sandbox.object_id().to_string(); let new_policy_clone = new_policy.clone(); - state + let annotations = req.annotations.clone(); + let updated_sandbox = state .store .update_message_cas::( &sandbox_id, @@ -1874,10 +1969,16 @@ async fn handle_update_config_inner( { spec.policy = Some(new_policy_clone.clone()); } + if !annotations.is_empty() + && let Some(metadata) = sandbox.metadata.as_mut() + { + metadata.annotations.extend(annotations.clone()); + } }, ) .await .map_err(|e| super::persistence_error_to_status(e, "backfill spec.policy"))?; + response_annotations = sandbox_metadata_annotations(&updated_sandbox); info!( sandbox_id = %sandbox_id, "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" @@ -1896,12 +1997,22 @@ async fn handle_update_config_inner( if let Some(ref current) = latest && current.policy_hash == hash { - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(current.version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })); + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + + return Ok(update_config_response( + u32::try_from(current.version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )); } let next_version = latest.map_or(1, |r| r.version + 1); @@ -1918,6 +2029,15 @@ async fn handle_update_config_inner( .supersede_older_policies(&sandbox_id, next_version) .await; + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + state.sandbox_watch_bus.notify(&sandbox_id); info!( @@ -1928,12 +2048,13 @@ async fn handle_update_config_inner( ); emit_full_policy_update_success(sandbox_caller, next_version); - Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(next_version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })) + Ok(update_config_response( + u32::try_from(next_version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )) } // --------------------------------------------------------------------------- @@ -10110,6 +10231,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + annotations: HashMap::new(), }), ) .await @@ -10137,6 +10259,424 @@ mod tests { ); } + #[tokio::test] + async fn update_config_policy_backfill_persists_and_returns_annotations() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + + let state = test_server_state().await; + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-annotated-backfill".to_string(), + name: "annotated-backfill".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::from([( + "openshell.nvidia.com/existing".to_string(), + "keep".to_string(), + )]), + }), + spec: Some(SandboxSpec { + policy: None, + providers: Vec::new(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let current = state + .store + .get_message_by_name::("annotated-backfill") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + let annotations = HashMap::from([ + ( + "openshell.nvidia.com/policy-signature".to_string(), + "signed-policy".to_string(), + ), + ( + "openshell.nvidia.com/policy-provenance".to_string(), + "governance-interceptor".to_string(), + ), + ]); + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "annotated-backfill".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: current_version, + annotations: annotations.clone(), + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 1); + assert_eq!( + response.annotations.get("openshell.nvidia.com/existing"), + Some(&"keep".to_string()) + ); + for (key, value) in &annotations { + assert_eq!(response.annotations.get(key), Some(value)); + } + + let stored = state + .store + .get_message_by_name::("annotated-backfill") + .await + .unwrap() + .unwrap(); + let stored_annotations = &stored.metadata.as_ref().unwrap().annotations; + assert_eq!( + stored_annotations.get("openshell.nvidia.com/existing"), + Some(&"keep".to_string()) + ); + for (key, value) in &annotations { + assert_eq!(stored_annotations.get(key), Some(value)); + } + assert!( + stored.spec.as_ref().unwrap().policy.is_some(), + "policy should still be backfilled" + ); + } + + #[tokio::test] + async fn update_config_same_policy_hash_persists_and_returns_annotations() { + let state = test_server_state().await; + let mut policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut policy); + let hash = deterministic_policy_hash(&policy); + let sandbox = test_sandbox("sb-same-hash", "same-hash", policy.clone(), Vec::new()); + state.store.put_message(&sandbox).await.unwrap(); + state + .store + .put_policy_revision( + "policy-same-hash-v1", + "sb-same-hash", + 1, + &policy.encode_to_vec(), + &hash, + ) + .await + .unwrap(); + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "same-hash".to_string(), + policy: Some(policy), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-hash-signature".to_string(), + )]), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 1); + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("same-hash-signature") + ); + + let stored = state + .store + .get_message_by_name::("same-hash") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("same-hash-signature") + ); + } + + #[tokio::test] + async fn update_config_full_policy_empty_annotations_preserves_existing_annotations() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "old.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + let mut sandbox = test_sandbox( + "sb-preserve-full", + "preserve-full", + baseline.clone(), + Vec::new(), + ); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/policy-signature".to_string(), + "keep".to_string(), + ); + state.store.put_message(&sandbox).await.unwrap(); + state + .store + .put_policy_revision( + "policy-preserve-full-v1", + "sb-preserve-full", + 1, + &baseline.encode_to_vec(), + &deterministic_policy_hash(&baseline), + ) + .await + .unwrap(); + + let mut updated = test_policy_with_rule("sandbox_only", "new.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut updated); + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "preserve-full".to_string(), + policy: Some(updated), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-full") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + } + + #[tokio::test] + async fn update_config_merge_empty_annotations_preserves_existing_annotations() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + let mut sandbox = test_sandbox("sb-preserve-merge", "preserve-merge", baseline, Vec::new()); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/policy-provenance".to_string(), + "keep".to_string(), + ); + state.store.put_message(&sandbox).await.unwrap(); + + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "preserve-merge".to_string(), + merge_operations: vec![PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::AddRule( + openshell_core::proto::AddNetworkRule { + rule_name: "allow_api_example".to_string(), + rule: Some(NetworkPolicyRule { + name: "allow_api_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }, + )), + }], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-provenance") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-merge") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-provenance") + .map(String::as_str), + Some("keep") + ); + } + + #[tokio::test] + async fn update_config_backfill_empty_annotations_preserves_existing_annotations() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + + let state = test_server_state().await; + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-preserve-backfill".to_string(), + name: "preserve-backfill".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "keep".to_string(), + )]), + }), + spec: Some(SandboxSpec { + policy: None, + providers: Vec::new(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let current = state + .store + .get_message_by_name::("preserve-backfill") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "preserve-backfill".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + expected_resource_version: current_version, + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-backfill") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + assert!( + stored.spec.as_ref().unwrap().policy.is_some(), + "policy should still be backfilled" + ); + } + + #[tokio::test] + async fn update_config_global_rejects_annotations() { + let state = test_server_state().await; + let err = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::StringValue("auto".to_string())), + }), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "global".to_string(), + )]), + ..Default::default() + })), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("sandbox-scoped")); + } + + #[tokio::test] + async fn update_config_rejects_invalid_annotations() { + let state = test_server_state().await; + state + .store + .put_message(&test_sandbox( + "sb-invalid-annotation", + "invalid-annotation", + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .unwrap(); + + let err = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "invalid-annotation".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + annotations: HashMap::from([("bad key".to_string(), "value".to_string())]), + ..Default::default() + })), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("label key")); + } + #[tokio::test] async fn update_config_user_policy_rejects_reserved_provider_key() { let state = test_server_state().await; @@ -10182,6 +10722,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10320,6 +10861,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + annotations: HashMap::new(), }), ) .await @@ -10406,6 +10948,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, + annotations: HashMap::new(), }), ) .await diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..0d59c58589 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -575,6 +575,8 @@ pub struct App { pub sandbox_notes: Vec, /// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string). pub sandbox_labels: Vec, + /// Formatted annotations for each sandbox (e.g., "policy-signature=abc" or empty string). + pub sandbox_annotations: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -689,6 +691,11 @@ pub fn format_labels(labels: &HashMap) -> String { .join(",") } +/// Format object annotations as a comma-separated key=value string. +pub fn format_annotations(annotations: &HashMap) -> String { + format_labels(annotations) +} + pub fn provider_name(provider: &openshell_core::proto::Provider) -> &str { provider .metadata @@ -903,6 +910,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), + sandbox_annotations: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -2765,6 +2773,7 @@ impl App { self.sandbox_images.clear(); self.sandbox_notes.clear(); self.sandbox_labels.clear(); + self.sandbox_annotations.clear(); self.sandbox_policy_versions.clear(); self.sandbox_selected = 0; self.sandbox_count = 0; diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 4350890f31..bdca8932b8 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -2107,13 +2107,10 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2143,13 +2140,10 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key, - setting_value: None, delete_setting: true, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2213,13 +2207,9 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name, - policy: None, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2253,13 +2243,9 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name, - policy: None, setting_key: key, - setting_value: None, delete_setting: true, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2378,6 +2364,16 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); + app.sandbox_annotations = sandboxes + .iter() + .map(|s| { + s.metadata + .as_ref() + .map(|metadata| app::format_annotations(&metadata.annotations)) + .unwrap_or_default() + }) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { app.sandbox_selected = app.sandbox_count - 1; } diff --git a/crates/openshell-tui/src/ui/sandbox_detail.rs b/crates/openshell-tui/src/ui/sandbox_detail.rs index 7cdbec8bd0..f212f21b06 100644 --- a/crates/openshell-tui/src/ui/sandbox_detail.rs +++ b/crates/openshell-tui/src/ui/sandbox_detail.rs @@ -84,29 +84,40 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(labels_str, t.text), ]); - // Row 4: Providers + // Row 4: Annotations + let annotations_str = app + .sandbox_annotations + .get(idx) + .filter(|s| !s.is_empty()) + .map_or("none", String::as_str); + let row4 = Line::from(vec![ + Span::styled(" Annotations: ", t.muted), + Span::styled(annotations_str, t.text), + ]); + + // Row 5: Providers let providers_str = if app.sandbox_providers_list.is_empty() { "none".to_string() } else { app.sandbox_providers_list.join(", ") }; - let row4 = Line::from(vec![ + let row5 = Line::from(vec![ Span::styled(" Providers: ", t.muted), Span::styled(providers_str, t.text), ]); - // Row 5: Forwarded Ports + // Row 6: Forwarded Ports let forwards_str = app .sandbox_notes .get(idx) .filter(|s| !s.is_empty()) .map_or("none", String::as_str); - let row5 = Line::from(vec![ + let row6 = Line::from(vec![ Span::styled(" Forwards: ", t.muted), Span::styled(forwards_str, t.text), ]); - let mut lines = vec![Line::from(""), row1, row2, row3, row4, row5]; + let mut lines = vec![row1, row2, row3, row4, row5, row6]; // Show global policy indicator when the sandbox's policy is managed at // gateway scope. diff --git a/proto/openshell.proto b/proto/openshell.proto index 5bba9560fa..4b19c366c9 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1228,6 +1228,9 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; + // Optional annotations to merge into sandbox metadata after a successful + // sandbox-scoped update. Intended for non-secret provenance metadata. + map annotations = 9; } message PolicyMergeOperation { @@ -1283,6 +1286,8 @@ message UpdateConfigResponse { uint64 settings_revision = 3; // True when a setting delete operation removed an existing key. bool deleted = 4; + // Sandbox metadata annotations after the update. Empty for global updates. + map annotations = 5; } // Get sandbox policy status request. From b2440c3e14a3224d01b14d0dadf0885eb9fad3bf Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 16:52:48 -0700 Subject: [PATCH 10/27] feat(providers): support interceptor profile catalogs Signed-off-by: Drew Newberry --- architecture/gateway.md | 7 + crates/openshell-cli/src/run.rs | 9 +- .../tests/provider_commands_integration.rs | 39 +- crates/openshell-core/src/proto/mod.rs | 8 + .../openshell-gateway-interceptors/src/lib.rs | 91 +++- crates/openshell-providers/src/lib.rs | 11 +- crates/openshell-providers/src/profiles.rs | 42 +- crates/openshell-server/src/grpc/policy.rs | 190 +++++---- crates/openshell-server/src/grpc/provider.rs | 344 +++++++++------ crates/openshell-server/src/lib.rs | 19 + .../src/provider_profile_catalog.rs | 394 ++++++++++++++++++ docs/sandboxes/providers-v2.mdx | 12 +- examples/governance-interceptor/README.md | 34 +- examples/governance-interceptor/smoke.sh | 141 ++++++- examples/governance-interceptor/src/main.rs | 197 ++------- proto/gateway_interceptor.proto | 18 + 16 files changed, 1091 insertions(+), 465 deletions(-) create mode 100644 crates/openshell-server/src/provider_profile_catalog.rs diff --git a/architecture/gateway.md b/architecture/gateway.md index 6023492b13..ec6c5f2755 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,6 +52,13 @@ 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. +Interceptor manifests can also vend provider profile catalogs. The gateway +always starts with the in-tree built-in catalog source, then merges any +interceptor-declared sources. An authoritative interceptor catalog becomes the +visible provider profile source of truth for that gateway and hides built-in +and user-imported profiles from profile resolution, while append catalogs add +static profiles alongside the built-in/user catalog. + Supported auth modes: | Mode | Use | diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 516e0449ef..f855b0ec66 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4726,13 +4726,14 @@ pub async fn provider_create_with_options( }; let adc_credential_key = if from_gcloud_adc { - let profile = - openshell_providers::get_default_profile(&provider_type).ok_or_else(|| { + let profile = fetch_provider_profile(&mut client, &provider_type) + .await + .map_err(|err| { miette::miette!( - "--from-gcloud-adc requires a built-in provider profile, \ - but '{provider_type}' has none" + "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" ) })?; + let profile = ProviderTypeProfile::from_proto(&profile); let adc_cred = profile.adc_credential().ok_or_else(|| { miette::miette!( "--from-gcloud-adc is not supported for '{provider_type}' providers \ diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 3987d292cf..d75fe26e20 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -344,21 +344,23 @@ impl OpenShell for TestOpenShell { .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; if provider.credentials.is_empty() { - let bootstrap_allowed = - if let Some(profile) = openshell_providers::get_default_profile(&provider.r#type) { - profile.allows_empty_provider_credentials() - } else { - self.state - .profiles - .lock() - .await - .get(&provider.r#type) - .cloned() - .is_some_and(|profile| { - openshell_providers::ProviderTypeProfile::from_proto(&profile) - .allows_empty_provider_credentials() - }) - }; + let bootstrap_allowed = if let Some(profile) = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == provider.r#type) + { + profile.allows_empty_provider_credentials() + } else { + self.state + .profiles + .lock() + .await + .get(&provider.r#type) + .cloned() + .is_some_and(|profile| { + openshell_providers::ProviderTypeProfile::from_proto(&profile) + .allows_empty_provider_credentials() + }) + }; if !bootstrap_allowed { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -415,7 +417,7 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - let mut profiles = openshell_providers::default_profiles() + let mut profiles = openshell_providers::builtin_profiles() .iter() .map(openshell_providers::ProviderTypeProfile::to_proto) .collect::>(); @@ -430,7 +432,10 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let id = request.into_inner().id; - let profile = if let Some(profile) = openshell_providers::get_default_profile(&id) { + let profile = if let Some(profile) = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + { profile.to_proto() } else { self.state diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 2cd82ff764..96424056fd 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -16,6 +16,14 @@ pub mod openshell { include!(concat!(env!("OUT_DIR"), "/openshell.v1.rs")); } +// Cross-package references from packages nested under `openshell.*.v1` can be +// generated as `super::super::v1::*`. Keep that path available as an alias for +// the root `openshell.v1` package. +#[doc(hidden)] +pub mod v1 { + pub use super::openshell::*; +} + #[allow( clippy::all, clippy::pedantic, diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 3b2b9492cc..e8da5c5ef3 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -200,6 +200,7 @@ impl std::fmt::Debug for BindingPlan { #[derive(Debug, Clone)] pub struct GatewayInterceptorRuntime { bindings: Arc>>, + profile_catalog_sources: Arc>, routes: Arc, descriptors: Arc, } @@ -217,6 +218,19 @@ pub struct InterceptedRequest { operation: Value, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileCatalogMode { + Append, + Authoritative, +} + +#[derive(Debug, Clone)] +pub struct ProfileCatalogSource { + pub source_id: String, + pub mode: ProfileCatalogMode, + pub profiles: Vec, +} + /// Return `None` when no interceptors are configured. pub async fn initialize( configs: Vec, @@ -237,6 +251,8 @@ impl GatewayInterceptorRuntime { let descriptors = ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); + let mut profile_catalog_sources = Vec::new(); + let mut profile_catalog_source_ids = BTreeSet::new(); for config in configs { validate_service_config(&config)?; @@ -318,17 +334,39 @@ impl GatewayInterceptorRuntime { .push(plan); } } + + if let Some(source) = + normalize_profile_catalog_source(&config.name, manifest.provider_profile_catalog)? + { + if !profile_catalog_source_ids.insert(source.source_id.clone()) { + return Err(InterceptorError::Config(format!( + "duplicate provider profile catalog source id '{}'", + source.source_id + ))); + } + profile_catalog_sources.push(source); + } } let count: usize = bindings.values().map(Vec::len).sum(); - info!(bindings = count, "gateway interceptors initialized"); + info!( + bindings = count, + profile_catalog_sources = profile_catalog_sources.len(), + "gateway interceptors initialized" + ); Ok(Self { bindings: Arc::new(bindings), + profile_catalog_sources: Arc::new(profile_catalog_sources), routes: Arc::new(routes), descriptors: Arc::new(descriptors), }) } + #[must_use] + pub fn profile_catalog_sources(&self) -> &[ProfileCatalogSource] { + self.profile_catalog_sources.as_ref() + } + #[must_use] pub fn is_empty(&self) -> bool { self.bindings.is_empty() @@ -603,6 +641,57 @@ fn normalize_binding( })) } +fn normalize_profile_catalog_source( + interceptor_name: &str, + catalog: Option, +) -> Result> { + let Some(catalog) = catalog else { + return Ok(None); + }; + + let source_id = catalog.source_id.trim(); + if source_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' provider profile catalog source_id must not be empty" + ))); + } + + let mode = + openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::try_from( + catalog.mode, + ) + .map_err(|_| { + InterceptorError::Config(format!( + "interceptor '{interceptor_name}' provider profile catalog has unknown mode" + )) + })?; + let mode = match mode { + openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Append => { + ProfileCatalogMode::Append + } + openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Authoritative => { + ProfileCatalogMode::Authoritative + } + openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Unspecified => { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' provider profile catalog mode must not be unspecified" + ))); + } + }; + + if mode == ProfileCatalogMode::Authoritative && catalog.profiles.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' authoritative provider profile catalog must not be empty" + ))); + } + + Ok(Some(ProfileCatalogSource { + source_id: source_id.to_string(), + mode, + profiles: catalog.profiles, + })) +} + fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { let selector = selector .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index b15525e372..1d78fe3612 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -19,9 +19,8 @@ pub use context::{DiscoveryContext, RealDiscoveryContext}; pub use discovery::{discover_from_profile, discover_with_spec}; pub use profiles::{ CredentialRefreshProfile, ProfileError, ProfileValidationDiagnostic, ProviderTypeProfile, - default_profiles, get_default_profile, normalize_profile_id, parse_profile_json, - parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, - validate_profile_set, + builtin_profiles, normalize_profile_id, parse_profile_json, parse_profile_yaml, + profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, validate_profile_set, }; #[derive(Debug, thiserror::Error)] @@ -152,12 +151,14 @@ impl ProviderRegistry { #[must_use] pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { - get_default_profile(id) + builtin_profiles() + .iter() + .find(|profile| profile.id.eq_ignore_ascii_case(id)) } #[must_use] pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { - default_profiles().iter().collect() + builtin_profiles().iter().collect() } /// Inject provider-specific env vars via the registered plugin. diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index ddfbcaf7dc..3820c346cb 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1696,11 +1696,11 @@ fn is_kubernetes_service_host(host: &str) -> bool { (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) } -static DEFAULT_PROFILES: OnceLock> = OnceLock::new(); +static BUILTIN_PROFILES: OnceLock> = OnceLock::new(); #[must_use] -pub fn default_profiles() -> &'static [ProviderTypeProfile] { - DEFAULT_PROFILES +pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { + BUILTIN_PROFILES .get_or_init(|| { parse_profile_catalog_yamls(BUILT_IN_PROFILE_YAMLS) .expect("built-in provider profiles must be valid YAML") @@ -1708,26 +1708,26 @@ pub fn default_profiles() -> &'static [ProviderTypeProfile] { .as_slice() } -#[must_use] -pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { - default_profiles() - .iter() - .find(|profile| profile.id.eq_ignore_ascii_case(id)) -} - #[cfg(test)] mod tests { use openshell_core::proto::ProviderProfileCategory; use super::{ - DiscoveryProfile, ProfileError, ProviderTypeProfile, default_profiles, get_default_profile, + DiscoveryProfile, ProfileError, ProviderTypeProfile, builtin_profiles, normalize_profile_id, parse_profile_catalog_yamls, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, validate_profile_set, }; + fn builtin_profile(id: &str) -> &'static ProviderTypeProfile { + builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .unwrap_or_else(|| panic!("built-in profile {id} should exist")) + } + #[test] - fn default_profiles_are_sorted_by_id() { - let ids = default_profiles() + fn builtin_profiles_are_sorted_by_id() { + let ids = builtin_profiles() .iter() .map(|profile| profile.id.as_str()) .collect::>(); @@ -1738,7 +1738,7 @@ mod tests { #[test] fn github_profile_materializes_policy_metadata() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); let proto = profile.to_proto(); assert_eq!(proto.id, "github"); @@ -1768,7 +1768,7 @@ mod tests { #[test] fn credential_env_vars_are_deduplicated_in_profile_order() { - let profile = get_default_profile("claude-code").expect("claude-code profile"); + let profile = builtin_profile("claude-code"); assert_eq!( profile.credential_env_vars(), vec!["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] @@ -1777,7 +1777,7 @@ mod tests { #[test] fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { - let profile = get_default_profile("google-vertex-ai").expect("vertex profile"); + let profile = builtin_profile("google-vertex-ai"); let service_account_token = profile .credentials .iter() @@ -1875,13 +1875,13 @@ credentials: #[test] fn adc_credential_returns_oauth2_refresh_token_credential_with_adc_material() { - let profile = get_default_profile("google-cloud").expect("google-cloud profile"); + let profile = builtin_profile("google-cloud"); let adc = profile .adc_credential() .expect("google-cloud should have an ADC credential"); assert_eq!(adc.env_vars[0], "GCP_ADC_ACCESS_TOKEN"); - let profile = get_default_profile("google-vertex-ai").expect("vertex profile"); + let profile = builtin_profile("google-vertex-ai"); let adc = profile .adc_credential() .expect("vertex should have an ADC credential"); @@ -1890,10 +1890,10 @@ credentials: #[test] fn adc_credential_returns_none_for_profiles_without_adc() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); assert!(profile.adc_credential().is_none()); - let profile = get_default_profile("claude-code").expect("claude-code profile"); + let profile = builtin_profile("claude-code"); assert!(profile.adc_credential().is_none()); } @@ -2433,7 +2433,7 @@ endpoints: #[test] fn profile_json_round_trip_preserves_compact_dto_shape() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); let json = profile_to_json(profile).expect("profile should serialize"); let parsed = parse_profile_json(&json).expect("profile should parse"); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9059306f7a..e4b748dac0 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -14,6 +14,7 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; use crate::policy_store::PolicyStoreExt; +use crate::provider_profile_catalog::ProviderProfileCatalog; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; @@ -61,7 +62,7 @@ use openshell_prover::{ registry::load_embedded_binary_registry, report::finding_shorthand, }; -use openshell_providers::{get_default_profile, normalize_provider_type}; +use openshell_providers::normalize_provider_type; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -517,8 +518,9 @@ fn run_prover_findings( /// a `warn!` — the merged policy already excludes them at compose time, so /// silently treating them as absent here keeps the credential set consistent /// with the merged policy the prover validates against. -async fn build_credential_set_for_sandbox( +async fn build_credential_set_for_sandbox_with_catalog( store: &Store, + catalog: &ProviderProfileCatalog, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); @@ -534,28 +536,17 @@ async fn build_credential_set_for_sandbox( }; let provider_type = provider.r#type.trim(); - let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { - let Some(profile) = get_default_profile(canonical_type) else { - warn!( - provider_name = %name, - provider_type, - "legacy provider type has no profile; skipping credential entry" - ); - continue; - }; - profile.clone() - } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping credential entry" - ); - continue; - }; - profile + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + let Some(profile) = + super::provider::get_provider_type_profile_with_catalog(store, catalog, profile_id) + .await? + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping credential entry" + ); + continue; }; let target_hosts: Vec = profile @@ -1023,8 +1014,12 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &provider_names).await?; + let provider_layers = profile_provider_policy_layers_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &provider_names, + ) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1292,8 +1287,12 @@ pub(super) async fn handle_get_sandbox_config( && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() { - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_layers = profile_provider_policy_layers_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &sandbox_provider_names, + ) + .await?; if !provider_layers.is_empty() { let effective_policy = compose_effective_policy(source_policy, &provider_layers); policy_hash = deterministic_policy_hash(&effective_policy); @@ -1303,8 +1302,12 @@ pub(super) async fn handle_get_sandbox_config( let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); - let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_env_revision = compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &sandbox_provider_names, + ) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1318,9 +1321,23 @@ pub(super) async fn handle_get_sandbox_config( })) } -pub(super) async fn compute_provider_env_revision( +#[cfg(test)] +async fn compute_provider_env_revision( store: &Store, provider_names: &[String], +) -> Result { + compute_provider_env_revision_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider_names, + ) + .await +} + +pub(super) async fn compute_provider_env_revision_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); hasher.update(b"openshell-provider-env-revision-v1"); @@ -1341,7 +1358,8 @@ pub(super) async fn compute_provider_env_revision( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, &provider.r#type, &mut hasher).await?; + hash_provider_profile_revision(store, catalog, &provider.r#type, &mut hasher) + .await?; let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1369,42 +1387,33 @@ pub(super) async fn compute_provider_env_revision( async fn hash_provider_profile_revision( store: &Store, + catalog: &ProviderProfileCatalog, provider_type: &str, hasher: &mut Sha256, ) -> Result<(), Status> { - if let Some(profile) = get_default_profile(provider_type) { - hasher.update(b"builtin-profile"); - hasher.update(profile.to_proto().encode_to_vec()); - return Ok(()); - } - - hasher.update(b"custom-profile"); - match store - .get_by_name( - openshell_core::proto::StoredProviderProfile::object_type(), - provider_type, - ) + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + catalog + .hash_profile_revision(store, profile_id, hasher) .await - .map_err(|e| { - Status::internal(format!( - "fetch provider profile '{provider_type}' failed: {e}" - )) - })? { - Some(record) => { - hasher.update(record.id.as_bytes()); - hasher.update(record.updated_at_ms.to_le_bytes()); - hasher.update(record.payload.as_slice()); - } - None => { - hasher.update(b"missing"); - } - } - Ok(()) } +#[cfg(test)] async fn profile_provider_policy_layers( store: &Store, provider_names: &[String], +) -> Result, Status> { + profile_provider_policy_layers_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider_names, + ) + .await +} + +async fn profile_provider_policy_layers_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); @@ -1416,28 +1425,17 @@ async fn profile_provider_policy_layers( .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { - let Some(profile) = get_default_profile(canonical_type) else { - warn!( - provider_name = %name, - provider_type, - "legacy provider type has no profile; skipping provider policy layer" - ); - continue; - }; - profile.clone() - } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping provider policy layer" - ); - continue; - }; - profile + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + let Some(profile) = + super::provider::get_provider_type_profile_with_catalog(store, catalog, profile_id) + .await? + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping provider policy layer" + ); + continue; }; let rule_name = openshell_policy::provider_rule_name(provider.object_name()); @@ -1492,11 +1490,18 @@ pub(super) async fn handle_get_sandbox_provider_environment( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_names = spec.providers; - let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &provider_names).await?; - let provider_environment = - super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names) - .await?; + let provider_env_revision = compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &provider_names, + ) + .await?; + let provider_environment = super::provider::resolve_provider_environment_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &provider_names, + ) + .await?; info!( sandbox_id = %sandbox_id, @@ -2403,8 +2408,12 @@ pub(super) async fn handle_submit_policy_analysis( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = - build_credential_set_for_sandbox(state.store.as_ref(), &provider_names_for_creds).await?; + let credential_set = build_credential_set_for_sandbox_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &provider_names_for_creds, + ) + .await?; let current_version = state .store @@ -4613,7 +4622,7 @@ mod tests { } #[tokio::test] - async fn provider_policy_layers_skip_custom_profile_for_legacy_provider_type() { + async fn provider_policy_layers_resolve_user_profile_for_normalized_provider_type() { let store = test_store().await; store .put_message(&test_provider("custom-provider", "generic")) @@ -4653,7 +4662,8 @@ mod tests { .await .unwrap(); - assert!(layers.is_empty()); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a139205181..8389340c0d 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -8,6 +8,10 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; +use crate::provider_profile_catalog::{ + ProviderProfileCatalog, profile_response_payload, profile_storage_payload, + stored_profile_resource_version, stored_provider_profile, +}; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential, Sandbox, @@ -63,8 +67,22 @@ impl ProviderEnvironment { } } +#[cfg(test)] pub(super) async fn create_provider_record( store: &Store, + provider: Provider, +) -> Result { + create_provider_record_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider, + ) + .await +} + +pub(super) async fn create_provider_record_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -99,7 +117,7 @@ pub(super) async fn create_provider_record( return Err(Status::invalid_argument("provider.type is required")); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, &provider.r#type).await? + && !provider_type_allows_empty_credentials(store, catalog, &provider.r#type).await? { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -177,6 +195,19 @@ pub(super) async fn list_provider_records( pub(super) async fn update_provider_record( store: &Store, provider: Provider, +) -> Result { + update_provider_record_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider, + ) + .await +} + +pub(super) async fn update_provider_record_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -231,7 +262,8 @@ pub(super) async fn update_provider_record( // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes(store, &candidate).await?; + validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) + .await?; // Serialize labels for storage let labels_map = candidate.object_labels(); @@ -432,9 +464,23 @@ fn merge_i64_map( /// collects credential key-value pairs. Returns a map of environment variables /// to inject into the sandbox. Credential keys must be unique across attached /// providers so one provider cannot silently overwrite another provider's token. +#[cfg(test)] pub(super) async fn resolve_provider_environment( store: &Store, provider_names: &[String], +) -> Result { + resolve_provider_environment_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider_names, + ) + .await +} + +pub(super) async fn resolve_provider_environment_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider_names: &[String], ) -> Result { if provider_names.is_empty() { return Ok(ProviderEnvironment::default()); @@ -443,7 +489,8 @@ pub(super) async fn resolve_provider_environment( let mut env = std::collections::HashMap::new(); let mut expires = std::collections::HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, provider_names, None, now_ms).await?; + validate_provider_environment_keys_unique_at(store, catalog, provider_names, None, now_ms) + .await?; let registry = openshell_providers::ProviderRegistry::new(); for name in provider_names { @@ -496,7 +543,12 @@ pub(super) async fn resolve_provider_environment( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials(store, provider_names).await?, + dynamic_credentials: resolve_dynamic_credentials_with_catalog( + store, + catalog, + provider_names, + ) + .await?, }) } @@ -505,8 +557,9 @@ pub(super) async fn resolve_provider_environment( /// Returns a map of endpoint-bound keys to credential metadata for credentials /// that have `token_grant` configuration. Keys are internal supervisor metadata: /// host, port, endpoint path, and provider credential identity. -pub(super) async fn resolve_dynamic_credentials( +pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, + catalog: &ProviderProfileCatalog, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -528,7 +581,9 @@ pub(super) async fn resolve_dynamic_credentials( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = + get_provider_type_profile_with_catalog(store, catalog, profile_id).await? + else { continue; }; @@ -818,9 +873,23 @@ fn endpoint_path_matches(pattern: &str, path: &str) -> bool { pub async fn validate_provider_environment_keys_unique( store: &Store, provider_names: &[String], +) -> Result<(), Status> { + validate_provider_environment_keys_unique_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider_names, + ) + .await +} + +pub async fn validate_provider_environment_keys_unique_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, + catalog, provider_names, None, crate::persistence::current_time_ms(), @@ -828,8 +897,9 @@ pub async fn validate_provider_environment_keys_unique( .await } -pub async fn validate_provider_credential_key_available_for_attached_sandboxes( +pub async fn validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( store: &Store, + catalog: &ProviderProfileCatalog, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -839,12 +909,26 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes( .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); candidate.credential_expires_at_ms.remove(credential_key); - validate_provider_update_against_attached_sandboxes(store, &candidate).await + validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) + .await } pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, provider: &Provider, +) -> Result<(), Status> { + validate_provider_update_against_attached_sandboxes_with_catalog( + store, + &ProviderProfileCatalog::with_builtin_profiles(), + provider, + ) + .await +} + +pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( + store: &Store, + catalog: &ProviderProfileCatalog, + provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { @@ -854,6 +938,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( }; validate_provider_environment_keys_unique_at( store, + catalog, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -871,6 +956,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( async fn validate_provider_environment_keys_unique_at( store: &Store, + catalog: &ProviderProfileCatalog, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -900,7 +986,10 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + dynamic_bindings.extend( + dynamic_token_grant_bindings_for_provider_with_catalog(store, catalog, &provider) + .await?, + ); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -916,13 +1005,15 @@ struct DynamicTokenGrantBinding { score: u32, } -async fn dynamic_token_grant_bindings_for_provider( +async fn dynamic_token_grant_bindings_for_provider_with_catalog( store: &Store, + catalog: &ProviderProfileCatalog, provider: &Provider, ) -> Result, Status> { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = get_provider_type_profile_with_catalog(store, catalog, profile_id).await? + else { return Ok(Vec::new()); }; Ok(dynamic_token_grant_bindings_for_profile( @@ -1142,8 +1233,8 @@ use openshell_core::proto::{ UpdateProviderProfilesResponse, UpdateProviderRequest, }; use openshell_providers::{ - CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, default_profiles, - get_default_profile, normalize_profile_id, normalize_provider_type, validate_profile_set, + CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, + normalize_profile_id, normalize_provider_type, validate_profile_set, }; use std::sync::Arc; use tonic::{Request, Response}; @@ -1162,7 +1253,12 @@ pub(super) async fn handle_create_provider( return Err(Status::invalid_argument("provider is required")); }; let provider_type = provider.r#type.clone(); - let result = create_provider_record(state.store.as_ref(), provider).await; + let result = create_provider_record_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + provider, + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1208,12 +1304,6 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } -impl ObjectType for StoredProviderProfile { - fn object_type() -> &'static str { - "provider_profile" - } -} - pub(super) async fn handle_list_provider_profiles( state: &Arc, request: Request, @@ -1221,13 +1311,13 @@ pub(super) async fn handle_list_provider_profiles( let request = request.into_inner(); let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let mut profiles = merged_provider_profiles(state.store.as_ref()).await?; - profiles.sort_by(|left, right| left.id.cmp(&right.id)); - let profiles = profiles + let profiles = state + .provider_profile_catalog + .list_profiles(state.store.as_ref()) + .await? .into_iter() .skip(offset) .take(limit) - .map(|profile| profile.to_proto()) .collect(); Ok(Response::new(ListProviderProfilesResponse { profiles })) @@ -1239,10 +1329,11 @@ pub(super) async fn handle_get_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - let profile = get_provider_type_profile(state.store.as_ref(), &id) + let profile = state + .provider_profile_catalog + .get_profile(state.store.as_ref(), &id) .await? - .ok_or_else(|| Status::not_found("provider profile not found"))? - .to_proto(); + .ok_or_else(|| Status::not_found("provider profile not found"))?; Ok(Response::new(ProviderProfileResponse { profile: Some(profile), @@ -1257,11 +1348,24 @@ pub(super) async fn handle_import_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics.extend( + profile_conflict_diagnostics( + state.store.as_ref(), + &state.provider_profile_catalog, + &profiles, + ) + .await?, + ); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "import").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &state.provider_profile_catalog, + &profiles, + "import", + ) + .await?, ); } @@ -1315,7 +1419,13 @@ pub(super) async fn handle_update_provider_profiles( add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &profiles, &target_id).await?, + profile_update_target_diagnostics( + state.store.as_ref(), + &state.provider_profile_catalog, + &profiles, + &target_id, + ) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1343,7 +1453,13 @@ pub(super) async fn handle_update_provider_profiles( }; if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "update").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &state.provider_profile_catalog, + &profiles, + "update", + ) + .await?, ); } @@ -1417,7 +1533,14 @@ pub(super) async fn handle_lint_provider_profiles( let request = request.into_inner(); let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics.extend( + profile_conflict_diagnostics( + state.store.as_ref(), + &state.provider_profile_catalog, + &profiles, + ) + .await?, + ); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1433,10 +1556,13 @@ pub(super) async fn handle_delete_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - if get_default_profile(&id).is_some() { - return Err(Status::failed_precondition( - "built-in provider profiles cannot be deleted", - )); + if let Some(source_id) = state + .provider_profile_catalog + .static_source_for_profile(&id) + { + return Err(Status::failed_precondition(format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be deleted" + ))); } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; @@ -1466,38 +1592,26 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) async fn get_provider_type_profile( +pub(super) async fn get_provider_type_profile_with_catalog( store: &Store, + catalog: &ProviderProfileCatalog, id: &str, ) -> Result, Status> { let Some(id) = normalize_profile_id(id) else { return Ok(None); }; - if let Some(profile) = get_default_profile(&id) { - return Ok(Some(profile.clone())); - } - let profile = store - .get_message_by_name::(&id) - .await - .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? - .and_then(|stored| { - let resource_version = stored_profile_resource_version(&stored); - stored.profile.map(|profile| { - ProviderTypeProfile::from_proto(&profile_response_payload( - profile, - resource_version, - )) - }) - }); - Ok(profile) + catalog.get_type_profile(store, &id).await } async fn provider_refresh_defaults( store: &Store, + catalog: &ProviderProfileCatalog, provider: &Provider, credential_key: &str, ) -> Result, Status> { - let Some(profile) = get_provider_type_profile(store, &provider.r#type).await? else { + let Some(profile) = + get_provider_type_profile_with_catalog(store, catalog, &provider.r#type).await? + else { return Ok(None); }; Ok(profile @@ -1540,41 +1654,17 @@ fn validate_refresh_material( async fn provider_type_allows_empty_credentials( store: &Store, + catalog: &ProviderProfileCatalog, provider_type: &str, ) -> Result { - let Some(profile) = get_provider_type_profile(store, provider_type).await? else { + let Some(profile) = + get_provider_type_profile_with_catalog(store, catalog, provider_type).await? + else { return Ok(false); }; Ok(profile.allows_empty_provider_credentials()) } -async fn merged_provider_profiles(store: &Store) -> Result, Status> { - let mut profiles = default_profiles().to_vec(); - profiles.extend( - custom_provider_profiles(store) - .await? - .into_iter() - .filter_map(|stored| { - let resource_version = stored_profile_resource_version(&stored); - stored.profile.map(|profile| { - ProviderTypeProfile::from_proto(&profile_response_payload( - profile, - resource_version, - )) - }) - }), - ); - Ok(profiles) -} - -async fn custom_provider_profiles(store: &Store) -> Result, Status> { - let profiles: Vec = store - .list_messages(10_000, 0) - .await - .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; - Ok(profiles) -} - fn normalize_profile_id_request(id: &str) -> Result { if id.trim().is_empty() { return Err(Status::invalid_argument("id is required")); @@ -1626,6 +1716,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, + catalog: &ProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1633,12 +1724,14 @@ async fn profile_conflict_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if get_default_profile(&id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), field: "id".to_string(), - message: format!("provider profile '{id}' is built-in and cannot be overwritten"), + message: format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be overwritten" + ), severity: "error".to_string(), }); continue; @@ -1663,6 +1756,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, + catalog: &ProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1683,12 +1777,14 @@ async fn profile_update_target_diagnostics( }); } } - if get_default_profile(target_id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(target_id) { diagnostics.push(ProfileValidationDiagnostic { source: target_id.to_string(), profile_id: target_id.to_string(), field: "id".to_string(), - message: format!("provider profile '{target_id}' is built-in and cannot be updated"), + message: format!( + "provider profile '{target_id}' is managed by source '{source_id}' and cannot be updated" + ), severity: "error".to_string(), }); return Ok(diagnostics); @@ -1711,12 +1807,14 @@ async fn profile_update_target_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if get_default_profile(&id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), field: "id".to_string(), - message: format!("provider profile '{id}' is built-in and cannot be updated"), + message: format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be updated" + ), severity: "error".to_string(), }); } @@ -1726,6 +1824,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, + catalog: &ProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1776,7 +1875,12 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + bindings.extend( + dynamic_token_grant_bindings_for_provider_with_catalog( + store, catalog, &provider, + ) + .await?, + ); } } @@ -1802,43 +1906,6 @@ async fn profile_attached_sandbox_diagnostics( Ok(diagnostics) } -fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { - use crate::persistence::current_time_ms; - let now_ms = current_time_ms(); - let profile = profile_storage_payload(profile); - StoredProviderProfile { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: uuid::Uuid::new_v4().to_string(), - name: profile.id.clone(), - created_at_ms: now_ms, - labels: std::collections::HashMap::new(), - resource_version: 0, - annotations: std::collections::HashMap::new(), - }), - profile: Some(profile), - } -} - -fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { - profile.resource_version = 0; - profile -} - -fn profile_response_payload( - mut profile: ProviderProfile, - resource_version: u64, -) -> ProviderProfile { - profile.resource_version = resource_version; - profile -} - -fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { - stored - .metadata - .as_ref() - .map_or(0, |metadata| metadata.resource_version) -} - fn proto_diagnostic(diagnostic: ProfileValidationDiagnostic) -> ProviderProfileDiagnostic { ProviderProfileDiagnostic { source: diagnostic.source, @@ -1906,7 +1973,12 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let result = update_provider_record(state.store.as_ref(), provider).await; + let result = update_provider_record_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + provider, + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2060,14 +2132,20 @@ pub(super) async fn handle_configure_provider_refresh( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - validate_provider_credential_key_available_for_attached_sandboxes( + validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( + state.store.as_ref(), + &state.provider_profile_catalog, + &provider, + credential_key, + ) + .await?; + let refresh_defaults = provider_refresh_defaults( state.store.as_ref(), + &state.provider_profile_catalog, &provider, credential_key, ) .await?; - let refresh_defaults = - provider_refresh_defaults(state.store.as_ref(), &provider, credential_key).await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2772,7 +2850,7 @@ mod tests { assert!(built_in.diagnostics.iter().any(|diagnostic| { diagnostic .message - .contains("built-in and cannot be updated") + .contains("managed by source 'builtin' and cannot be updated") })); let missing = handle_update_provider_profiles( @@ -3277,7 +3355,7 @@ mod tests { response .diagnostics .iter() - .any(|diagnostic| diagnostic.message.contains("built-in")) + .any(|diagnostic| diagnostic.message.contains("managed by source 'builtin'")) ); } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 1796f7840a..90d81eeb00 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -35,6 +35,7 @@ mod inference; mod multiplex; mod persistence; pub(crate) mod policy_store; +mod provider_profile_catalog; mod provider_refresh; mod readiness; mod sandbox_index; @@ -151,6 +152,10 @@ pub struct ServerState { /// Immutable gateway interceptor execution plan. `None` when disabled. pub(crate) gateway_interceptors: Option, + + /// Gateway-local provider profile catalog assembled from built-ins and + /// profile-vending interceptors. User-imported profiles are read on demand. + pub(crate) provider_profile_catalog: provider_profile_catalog::ProviderProfileCatalog, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -202,6 +207,8 @@ impl ServerState { k8s_sa_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, + provider_profile_catalog: + provider_profile_catalog::ProviderProfileCatalog::with_builtin_profiles(), } } } @@ -274,6 +281,17 @@ pub(crate) async fn run_server( .map_err(|e| { Error::config(format!("gateway interceptor initialization failed: {e}")) })?; + let provider_profile_catalog = + provider_profile_catalog::ProviderProfileCatalog::from_interceptor_sources( + gateway_interceptors + .as_ref() + .map_or(&[][..], |runtime| runtime.profile_catalog_sources()), + ) + .map_err(|e| { + Error::config(format!( + "provider profile catalog initialization failed: {e}" + )) + })?; let mut state = ServerState::new( config.clone(), store.clone(), @@ -285,6 +303,7 @@ pub(crate) async fn run_server( oidc_cache, ); state.gateway_interceptors = gateway_interceptors; + state.provider_profile_catalog = provider_profile_catalog; // 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/provider_profile_catalog.rs b/crates/openshell-server/src/provider_profile_catalog.rs new file mode 100644 index 0000000000..cd62fa4484 --- /dev/null +++ b/crates/openshell-server/src/provider_profile_catalog.rs @@ -0,0 +1,394 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-local provider profile catalog. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use openshell_core::proto::{ProviderProfile, StoredProviderProfile}; +use openshell_providers::{ + ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, + validate_profile_set, +}; +use prost::Message as _; +use sha2::{Digest, Sha256}; +use tonic::Status; + +use crate::persistence::{ObjectType, Store}; + +const BUILTIN_SOURCE_ID: &str = "builtin"; + +impl ObjectType for StoredProviderProfile { + fn object_type() -> &'static str { + "provider_profile" + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileCatalogSourceMode { + Append, + Authoritative, +} + +impl ProfileCatalogSourceMode { + const fn as_revision_tag(self) -> &'static [u8] { + match self { + Self::Append => b"append", + Self::Authoritative => b"authoritative", + } + } +} + +#[derive(Debug, Clone)] +pub struct ProfileCatalogSource { + pub source_id: String, + pub mode: ProfileCatalogSourceMode, + pub profiles: Vec, +} + +#[derive(Debug, Clone)] +struct StaticProfileEntry { + source_id: String, + mode: ProfileCatalogSourceMode, + profile: ProviderTypeProfile, +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileCatalog { + visible_static: Arc>, + authoritative: bool, +} + +impl ProviderProfileCatalog { + pub fn from_interceptor_sources( + sources: &[openshell_gateway_interceptors::ProfileCatalogSource], + ) -> Result { + let mut catalog_sources = vec![builtin_profile_source()]; + for source in sources { + catalog_sources.push(ProfileCatalogSource { + source_id: source.source_id.clone(), + mode: match source.mode { + openshell_gateway_interceptors::ProfileCatalogMode::Append => { + ProfileCatalogSourceMode::Append + } + openshell_gateway_interceptors::ProfileCatalogMode::Authoritative => { + ProfileCatalogSourceMode::Authoritative + } + }, + profiles: source + .profiles + .iter() + .map(ProviderTypeProfile::from_proto) + .collect(), + }); + } + Self::from_sources(catalog_sources) + } + + pub fn with_builtin_profiles() -> Self { + Self::from_sources(vec![builtin_profile_source()]) + .expect("built-in provider profiles must form a valid catalog") + } + + pub fn from_sources(sources: Vec) -> Result { + let mut source_ids = BTreeSet::new(); + let authoritative_count = sources + .iter() + .filter(|source| source.mode == ProfileCatalogSourceMode::Authoritative) + .count(); + if authoritative_count > 1 { + return Err( + "multiple authoritative provider profile catalog sources configured".into(), + ); + } + + for source in &sources { + let source_id = source.source_id.trim(); + if source_id.is_empty() { + return Err("provider profile catalog source_id must not be empty".into()); + } + if !source_ids.insert(source_id.to_string()) { + return Err(format!( + "duplicate provider profile catalog source id '{source_id}'" + )); + } + if source.mode == ProfileCatalogSourceMode::Authoritative && source.profiles.is_empty() + { + return Err(format!( + "authoritative provider profile catalog source '{source_id}' must not be empty" + )); + } + validate_source_profiles(source)?; + } + + let authoritative = authoritative_count == 1; + let mut visible_static = BTreeMap::new(); + for source in sources.iter().filter(|source| { + if authoritative { + source.mode == ProfileCatalogSourceMode::Authoritative + } else { + source.mode == ProfileCatalogSourceMode::Append + } + }) { + for profile in &source.profiles { + let id = normalize_profile_id(&profile.id).ok_or_else(|| { + format!( + "provider profile '{}' in source '{}' has invalid id", + profile.id, source.source_id + ) + })?; + if visible_static + .insert( + id.clone(), + StaticProfileEntry { + source_id: source.source_id.clone(), + mode: source.mode, + profile: profile.clone(), + }, + ) + .is_some() + { + return Err(format!( + "duplicate visible provider profile id '{id}' across catalog sources" + )); + } + } + } + + Ok(Self { + visible_static: Arc::new(visible_static), + authoritative, + }) + } + + #[must_use] + pub fn static_source_for_profile(&self, id: &str) -> Option<&str> { + let id = normalize_profile_id(id)?; + self.visible_static + .get(&id) + .map(|entry| entry.source_id.as_str()) + } + + pub async fn list_profiles(&self, store: &Store) -> Result, Status> { + let mut profiles = self + .visible_static + .values() + .map(|entry| entry.profile.to_proto()) + .collect::>(); + if !self.authoritative { + for stored in user_provider_profiles(store).await? { + let resource_version = stored_profile_resource_version(&stored); + if let Some(profile) = stored.profile { + if normalize_profile_id(&profile.id) + .is_some_and(|id| self.visible_static.contains_key(&id)) + { + continue; + } + profiles.push(profile_response_payload(profile, resource_version)); + } + } + } + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(profiles) + } + + pub async fn get_profile( + &self, + store: &Store, + id: &str, + ) -> Result, Status> { + let Some(id) = normalize_profile_id(id) else { + return Ok(None); + }; + if let Some(entry) = self.visible_static.get(&id) { + return Ok(Some(entry.profile.to_proto())); + } + if self.authoritative { + return Ok(None); + } + let profile = store + .get_message_by_name::(&id) + .await + .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? + .and_then(|stored| { + let resource_version = stored_profile_resource_version(&stored); + stored + .profile + .map(|profile| profile_response_payload(profile, resource_version)) + }); + Ok(profile) + } + + pub async fn get_type_profile( + &self, + store: &Store, + id: &str, + ) -> Result, Status> { + Ok(self + .get_profile(store, id) + .await? + .as_ref() + .map(ProviderTypeProfile::from_proto)) + } + + pub async fn hash_profile_revision( + &self, + store: &Store, + profile_id: &str, + hasher: &mut Sha256, + ) -> Result<(), Status> { + let Some(profile_id) = normalize_profile_id(profile_id) else { + hasher.update(b"invalid-profile-id"); + return Ok(()); + }; + + if let Some(entry) = self.visible_static.get(&profile_id) { + hasher.update(b"catalog-profile"); + hasher.update(entry.source_id.as_bytes()); + hasher.update(entry.mode.as_revision_tag()); + hasher.update(entry.profile.to_proto().encode_to_vec()); + return Ok(()); + } + + if self.authoritative { + hasher.update(b"missing"); + return Ok(()); + } + + hasher.update(b"user-profile"); + match store + .get_by_name(StoredProviderProfile::object_type(), &profile_id) + .await + .map_err(|e| { + Status::internal(format!("fetch provider profile '{profile_id}' failed: {e}")) + })? { + Some(record) => { + hasher.update(record.id.as_bytes()); + hasher.update(record.updated_at_ms.to_le_bytes()); + hasher.update(record.payload.as_slice()); + } + None => { + hasher.update(b"missing"); + } + } + Ok(()) + } +} + +fn builtin_profile_source() -> ProfileCatalogSource { + ProfileCatalogSource { + source_id: BUILTIN_SOURCE_ID.to_string(), + mode: ProfileCatalogSourceMode::Append, + profiles: builtin_profiles().to_vec(), + } +} + +fn validate_source_profiles(source: &ProfileCatalogSource) -> Result<(), String> { + let profiles = source + .profiles + .iter() + .map(|profile| (source.source_id.clone(), profile.clone())) + .collect::>(); + let diagnostics = validate_profile_set(&profiles); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == "error") + { + return Err(format_diagnostic(diagnostic)); + } + Ok(()) +} + +fn format_diagnostic(diagnostic: ProfileValidationDiagnostic) -> String { + if diagnostic.profile_id.is_empty() { + format!("{}: {}", diagnostic.field, diagnostic.message) + } else { + format!( + "provider profile '{}' {}: {}", + diagnostic.profile_id, diagnostic.field, diagnostic.message + ) + } +} + +pub(crate) async fn user_provider_profiles( + store: &Store, +) -> Result, Status> { + let profiles: Vec = store + .list_messages(10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + Ok(profiles) +} + +pub(crate) fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + use crate::persistence::current_time_ms; + let now_ms = current_time_ms(); + let profile = profile_storage_payload(profile); + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: profile.id.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: std::collections::HashMap::new(), + }), + profile: Some(profile), + } +} + +pub(crate) fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { + profile.resource_version = 0; + profile +} + +pub(crate) fn profile_response_payload( + mut profile: ProviderProfile, + resource_version: u64, +) -> ProviderProfile { + profile.resource_version = resource_version; + profile +} + +pub(crate) fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { + stored + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile(id: &str) -> ProviderTypeProfile { + let mut profile = builtin_profiles() + .iter() + .find(|profile| profile.id == "github") + .expect("github built-in profile") + .clone(); + profile.id = id.to_string(); + profile.display_name = id.to_string(); + profile + } + + #[test] + fn authoritative_catalog_hides_builtin_sources_from_management_checks() { + let catalog = ProviderProfileCatalog::from_sources(vec![ + builtin_profile_source(), + ProfileCatalogSource { + source_id: "interceptor/test".to_string(), + mode: ProfileCatalogSourceMode::Authoritative, + profiles: vec![profile("slack")], + }, + ]) + .unwrap(); + + assert_eq!( + catalog.static_source_for_profile("slack"), + Some("interceptor/test") + ); + assert_eq!(catalog.static_source_for_profile("github"), None); + } +} diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 8ccb7859e9..035644a2f9 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -51,7 +51,8 @@ The feature flag controls provider-derived policy layers. OpenShell still suppor Providers v2 currently includes these user-facing features: -- Built-in provider profiles stored in the `providers/` directory of the GitHub repository. +- Built-in provider profiles loaded by the gateway by default. +- Gateway interceptors can vend append or authoritative provider profile catalogs for governed deployments. - `openshell provider list-profiles` with table, YAML, and JSON output. - `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. @@ -89,6 +90,13 @@ List available profiles: openshell provider list-profiles ``` +By default the gateway lists built-in profiles plus custom profiles imported +through the profile APIs. When a configured gateway interceptor vends an +authoritative provider profile catalog, that catalog becomes the visible source +of truth: list, export, provider creation, policy composition, and sandbox +provider environment resolution use the interceptor-vended profiles instead of +built-in or user-imported profiles. + Built-in Providers v2 profiles currently include: | Profile ID | Category | Credential environment variables | @@ -138,7 +146,7 @@ openshell provider profile update github-profile -f github-profile.yaml Exported custom profiles include `resource_version`. OpenShell requires that version during update so stale files cannot silently overwrite newer profile definitions. The target ID in the command must match the profile ID in the file. Update accepts one file at a time. If an update would make dynamic token grants ambiguous for an attached sandbox, OpenShell rejects it before changing the profile. -Custom profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. Built-in profile IDs and legacy provider aliases are reserved. Built-in profiles are read-only, and OpenShell rejects updating or deleting a built-in profile. OpenShell also rejects deleting a custom profile while a sandbox-attached provider uses it. +Custom profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. Built-in profile IDs and legacy provider aliases are reserved. Built-in and interceptor-managed profiles are read-only through the profile APIs. OpenShell also rejects deleting a custom profile while a sandbox-attached provider uses it. ### Category Enum diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index ed22917021..f474e3c5a8 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -18,8 +18,7 @@ extend OpenShell to provide advanced governance over sandbox policies. - users cannot replace or merge sandbox policy after sandbox creation - 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` +- users cannot import or update provider profiles outside the governed set - provider profile deletion is blocked by the interceptor Run the interceptor: @@ -28,8 +27,7 @@ Run the interceptor: cargo run -- \ --listen 127.0.0.1:18081 \ --policy policy.yaml \ - --profiles profiles \ - --gateway-endpoint http://127.0.0.1:8080 + --profiles profiles ``` At startup the example parses `policy.yaml`, converts it to the protobuf JSON @@ -45,15 +43,14 @@ 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 interceptor vends the loaded profiles through +`InterceptorManifest.provider_profile_catalog` with authoritative mode. While +the interceptor is attached, the gateway treats that catalog as the profile +source of truth: `provider list-profiles` shows only `github` and `slack`, and +the built-in/user profile catalog is hidden. The normal import, update, and +delete profile APIs remain available for gateways without an authoritative +catalog, but profiles managed by this interceptor cannot be changed through +those APIs. The signing key is generated in memory on each interceptor start. This keeps the example self-contained. Production governance services should load managed @@ -79,9 +76,16 @@ max_response_bytes = 1048576 max_patches = 32 ``` -Run the smoke test script to automatically start the gateway, interceptor, and test the -governance controls +Run the launcher script to start a local gateway with the interceptor attached. +The script prints the gateway endpoint and log paths, then keeps the gateway and +interceptor running until you press Ctrl-C: ```shell ./smoke.sh ``` + +To run the governance smoke test suite and stop the gateway when it completes: + +```shell +./smoke.sh --test-suite +``` diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 4cde6038d7..a2ee578762 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -6,14 +6,51 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" EXAMPLE_DIR="$ROOT/examples/governance-interceptor" +RUN_TEST_SUITE=0 + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + TMPDIR="$(mktemp -d)" LOG_DIR="$TMPDIR/logs" JWT_DIR="$TMPDIR/jwt" GATEWAY_CONFIG="$TMPDIR/gateway.toml" -SMOKE_LOG="$LOG_DIR/smoke.log" +SETUP_LOG="$LOG_DIR/setup.log" GATEWAY_LOG="$LOG_DIR/gateway.log" INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" -RUN_ID="governance-smoke-$$-$RANDOM" +if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then + RUN_ID="governance-smoke-$$-$RANDOM" +else + RUN_ID="governance-interactive-$$-$RANDOM" +fi SANDBOX_NAME="$RUN_ID-sandbox" mkdir -p "$LOG_DIR" @@ -79,7 +116,7 @@ choose_port_block() { fi done - echo "failed to find free local ports for smoke test" >&2 + echo "failed to find free local ports for governance interceptor launcher" >&2 exit 1 } @@ -104,7 +141,7 @@ dump_log_file() { } dump_logs() { - dump_log_file "smoke log" "$SMOKE_LOG" + dump_log_file "setup log" "$SETUP_LOG" dump_log_file "gateway log" "$GATEWAY_LOG" dump_log_file "interceptor log" "$INTERCEPTOR_LOG" } @@ -128,7 +165,7 @@ log_command() { printf '+' printf ' %q' "$@" printf '\n' - } >>"$SMOKE_LOG" + } >>"$SETUP_LOG" } run_setup_step() { @@ -137,7 +174,7 @@ run_setup_step() { printf 'INFO %s\n' "$label" log_command "$label" "$@" - if ! "$@" >>"$SMOKE_LOG" 2>&1; then + if ! "$@" >>"$SETUP_LOG" 2>&1; then fail "$label" fi } @@ -147,7 +184,7 @@ run_step() { shift log_command "$label" "$@" - if "$@" >>"$SMOKE_LOG" 2>&1; then + if "$@" >>"$SETUP_LOG" 2>&1; then pass "$label" else fail "$label" @@ -159,7 +196,7 @@ expect_failure() { shift log_command "$label" "$@" - if "$@" >>"$SMOKE_LOG" 2>&1; then + if "$@" >>"$SETUP_LOG" 2>&1; then fail "$label" else pass "$label" @@ -173,10 +210,25 @@ expect_output_contains() { local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" log_command "$label" "$@" - if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -Fq -- "$needle" "$output_file"; then + if "$@" >"$output_file" 2>>"$SETUP_LOG" && grep -Fq -- "$needle" "$output_file"; then + pass "$label" + else + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" + fi +} + +expect_output_not_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + + log_command "$label" "$@" + if "$@" >"$output_file" 2>>"$SETUP_LOG" && ! grep -Fq -- "$needle" "$output_file"; then pass "$label" else - cat "$output_file" >>"$SMOKE_LOG" 2>/dev/null || true + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true fail "$label" fi } @@ -195,16 +247,16 @@ expect_log_contains() { wait_for_profile() { local profile_id="$1" - local label="loads $profile_id provider profile" + local label="loading $profile_id provider profile" { printf '\n== %s ==\n' "$label" printf '+ wait for provider profile %q\n' "$profile_id" - } >>"$SMOKE_LOG" + } >>"$SETUP_LOG" for _ in {1..60}; do - if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SMOKE_LOG" 2>&1; then - pass "$label" + if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then + printf 'INFO %s\n' "$label" return fi sleep 1 @@ -256,8 +308,7 @@ start_interceptor() { "$EXAMPLE_DIR/target/debug/governance-interceptor" \ --listen "$INTERCEPTOR_ADDR" \ --policy "$EXAMPLE_DIR/policy.yaml" \ - --profiles "$EXAMPLE_DIR/profiles" \ - --gateway-endpoint "$GATEWAY_ENDPOINT" >"$INTERCEPTOR_LOG" 2>&1 & + --profiles "$EXAMPLE_DIR/profiles" >"$INTERCEPTOR_LOG" 2>&1 & INTERCEPTOR_PID=$! } @@ -284,7 +335,7 @@ wait_for_gateway() { fi if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then - pass "$label" + printf 'INFO %s\n' "$label" return fi @@ -294,7 +345,7 @@ wait_for_gateway() { fail "$label" } -run_suite() { +configure_gateway() { CLI=( env -u OPENSHELL_SANDBOX_POLICY @@ -302,11 +353,16 @@ run_suite() { --gateway-endpoint "$GATEWAY_ENDPOINT" ) - run_step "enables provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes + run_setup_step "enabling provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes wait_for_profile "github" wait_for_profile "slack" +} + +run_suite() { expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + expect_output_not_contains "hides codex profile" "codex" "${CLI[@]}" provider list-profiles + expect_output_not_contains "hides google cloud profile" "google-cloud" "${CLI[@]}" provider list-profiles cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' id: custom-slack @@ -345,17 +401,56 @@ EOF expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github } +print_ready() { + cat </dev/null; then + fail "gateway process exited" + fi + + if ! kill -0 "$INTERCEPTOR_PID" 2>/dev/null; then + fail "governance interceptor process exited" + fi + + sleep 1 + done +} + cd "$ROOT" 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 +run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle write_gateway_config start_interceptor start_gateway wait_for_gateway -run_suite - -echo "ALL PASS governance interceptor smoke" +configure_gateway + +if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then + run_suite + echo "ALL PASS governance interceptor smoke" +else + print_ready + wait_until_stopped +fi diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 8120a883fa..7ba112e22f 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ @@ -13,13 +13,12 @@ use jsonwebtoken::{ use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, + ProviderProfileCatalog, ProviderProfileCatalogMode, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; use openshell_core::proto::{ - GetProviderProfileRequest, GraphqlOperation, ImportProviderProfilesRequest, L7Allow, - L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, - ProviderProfileDiagnostic, ProviderProfileImportItem, SandboxPolicy, - UpdateProviderProfilesRequest, open_shell_client::OpenShellClient, + GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, + ProviderProfile, SandboxPolicy, }; use openshell_policy::parse_sandbox_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; @@ -28,8 +27,7 @@ use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value, json}; use sha2::{Digest, Sha256}; -use tonic::Code; -use tonic::transport::{Channel, Server}; +use tonic::transport::Server; use tonic::{Request, Response, Status}; const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature"; @@ -38,8 +36,7 @@ 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 PROFILE_RECONCILE_ATTEMPTS: usize = 60; -const PROFILE_RECONCILE_RETRY_DELAY: Duration = Duration::from_secs(1); +const PROFILE_CATALOG_SOURCE_ID: &str = "interceptor/provider-governance"; #[derive(Clone)] struct PolicySigner { @@ -139,19 +136,24 @@ struct GovernanceInterceptorService { policy_signature: String, policy_signer: PolicySigner, managed_profile_ids: Vec, + managed_profiles: Vec, } #[derive(Clone, Debug)] struct LoadedProviderProfile { - source: String, profile: ProviderProfile, } impl GovernanceInterceptorService { - fn from_yaml(policy_yaml: &str, managed_profile_ids: Vec) -> Result { - if managed_profile_ids.is_empty() { + fn from_yaml(policy_yaml: &str, profiles: Vec) -> Result { + if profiles.is_empty() { return Err("at least one provider profile must be loaded".to_string()); } + let managed_profile_ids = loaded_profile_ids(&profiles); + let managed_profiles = profiles + .into_iter() + .map(|loaded| loaded.profile) + .collect::>(); 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); @@ -165,13 +167,19 @@ impl GovernanceInterceptorService { policy_signature, policy_signer, managed_profile_ids, + managed_profiles, }) } - fn manifest() -> InterceptorManifest { + fn manifest(&self) -> InterceptorManifest { InterceptorManifest { name: "provider-governance".to_string(), failure_policy: "fail_closed".to_string(), + provider_profile_catalog: Some(ProviderProfileCatalog { + source_id: PROFILE_CATALOG_SOURCE_ID.to_string(), + mode: ProviderProfileCatalogMode::Authoritative as i32, + profiles: self.managed_profiles.clone(), + }), bindings: vec![ binding( "govern-create-sandbox", @@ -443,7 +451,7 @@ impl GatewayInterceptor for GovernanceInterceptorService { &self, _request: Request, ) -> Result, Status> { - Ok(Response::new(Self::manifest())) + Ok(Response::new(self.manifest())) } async fn evaluate( @@ -591,10 +599,7 @@ fn load_provider_profile_source( 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, - }) + Ok(LoadedProviderProfile { profile }) } fn profile_id_from_file_name(path: &Path) -> Result { @@ -660,126 +665,6 @@ 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_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { let Some(Value::Array(providers)) = value else { return false; @@ -1194,21 +1079,17 @@ async fn main() -> Result<(), Box> { }; 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)?; + let service = GovernanceInterceptorService::from_yaml(&policy_yaml, profiles)?; 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(", ") + "--gateway-endpoint is ignored; provider profiles are vended through the interceptor manifest ({endpoint})" ); } + println!( + "loaded provider profiles: {}", + service.managed_profile_ids.join(", ") + ); println!("governance interceptor listening on {listen}"); Server::builder() @@ -1228,11 +1109,7 @@ mod tests { fn service() -> GovernanceInterceptorService { let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); - GovernanceInterceptorService::from_yaml( - include_str!("../policy.yaml"), - loaded_profile_ids(&profiles), - ) - .unwrap() + GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml"), profiles).unwrap() } fn evaluation( @@ -1309,7 +1186,8 @@ mod tests { #[test] fn manifest_declares_governance_bindings() { - let manifest = GovernanceInterceptorService::manifest(); + let service = service(); + let manifest = service.manifest(); let ids: Vec<_> = manifest .bindings .iter() @@ -1322,6 +1200,17 @@ mod tests { assert!(ids.contains(&"govern-update-provider-profiles")); assert!(ids.contains(&"govern-delete-provider-profile")); assert_eq!(manifest.failure_policy, "fail_closed"); + let catalog = manifest + .provider_profile_catalog + .expect("manifest includes provider catalog"); + assert_eq!(catalog.source_id, PROFILE_CATALOG_SOURCE_ID); + assert_eq!(catalog.mode, ProviderProfileCatalogMode::Authoritative as i32); + let profile_ids = catalog + .profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(); + assert_eq!(profile_ids, vec!["github", "slack"]); } #[test] diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 26f3a411c3..ec0ba7e435 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.gateway_interceptor.v1; import "google/protobuf/struct.proto"; +import "openshell.proto"; // GatewayInterceptor lets an external governance service evaluate gateway // control-plane operations after OpenShell admission and before or after the @@ -69,6 +70,23 @@ message InterceptorManifest { // Optional default failure policy for bindings without their own policy. // Supported values are "fail_closed" and "fail_open". string failure_policy = 3; + // Optional profile catalog vended by this interceptor. + ProviderProfileCatalog provider_profile_catalog = 4; +} + +enum ProviderProfileCatalogMode { + PROVIDER_PROFILE_CATALOG_MODE_UNSPECIFIED = 0; + PROVIDER_PROFILE_CATALOG_MODE_APPEND = 1; + PROVIDER_PROFILE_CATALOG_MODE_AUTHORITATIVE = 2; +} + +message ProviderProfileCatalog { + // Stable source id, for example "interceptor/provider-governance". + string source_id = 1; + // Merge behavior for this source. + ProviderProfileCatalogMode mode = 2; + // Complete profile snapshot vended by this source. + repeated openshell.v1.ProviderProfile profiles = 3; } message InterceptorBinding { From 3c17b7aa7dab37d1f9b015da7cfb0f3d05e0de57 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 20:46:55 -0700 Subject: [PATCH 11/27] wip --- Cargo.lock | 1 + .../openshell-gateway-interceptors/Cargo.toml | 1 + .../openshell-gateway-interceptors/src/lib.rs | 167 ++++--- crates/openshell-server/src/grpc/policy.rs | 26 +- crates/openshell-server/src/grpc/provider.rs | 77 +-- crates/openshell-server/src/lib.rs | 29 +- .../src/provider_profile_catalog.rs | 394 --------------- .../src/provider_profile_sources.rs | 472 ++++++++++++++++++ examples/governance-interceptor/Cargo.lock | 9 +- examples/governance-interceptor/Cargo.toml | 1 + examples/governance-interceptor/README.md | 66 ++- .../profiles/github.yaml | 2 +- examples/governance-interceptor/smoke.sh | 11 +- examples/governance-interceptor/src/main.rs | 406 ++++++++------- proto/gateway_interceptor.proto | 27 +- 15 files changed, 914 insertions(+), 775 deletions(-) delete mode 100644 crates/openshell-server/src/provider_profile_catalog.rs create mode 100644 crates/openshell-server/src/provider_profile_sources.rs diff --git a/Cargo.lock b/Cargo.lock index 507693cadb..2463a35213 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3765,6 +3765,7 @@ dependencies = [ "prost", "prost-types", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tonic", diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index 7800d6628e..24e140a6c6 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -20,6 +20,7 @@ metrics = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["channel", "tls-native-roots"] } diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index e8da5c5ef3..e689f58820 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -24,9 +24,10 @@ use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, }; +use openshell_core::proto::ProviderProfile; use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, - InterceptorResult, InterceptorSelector, JsonPatch, + InterceptorResult, InterceptorSelector, JsonPatch, ProviderProfileSnapshotRequest, gateway_interceptor_client::GatewayInterceptorClient, }; use prost::Message as _; @@ -36,6 +37,7 @@ use prost_types::{ field_descriptor_proto::{Label, Type}, }; use serde_json::{Map, Number, Value}; +use sha2::Digest as _; use tokio::net::UnixStream; use tonic::codegen::http::Uri; use tonic::transport::{Channel, Endpoint}; @@ -200,7 +202,7 @@ impl std::fmt::Debug for BindingPlan { #[derive(Debug, Clone)] pub struct GatewayInterceptorRuntime { bindings: Arc>>, - profile_catalog_sources: Arc>, + profile_sources: Arc>, routes: Arc, descriptors: Arc, } @@ -218,17 +220,29 @@ pub struct InterceptedRequest { operation: Value, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProfileCatalogMode { - Append, - Authoritative, -} - #[derive(Debug, Clone)] -pub struct ProfileCatalogSource { +pub struct ProviderProfileSourceSnapshot { pub source_id: String, - pub mode: ProfileCatalogMode, - pub profiles: Vec, + pub revision: String, + pub profiles: Vec, +} + +#[derive(Clone)] +struct ProfileSourcePlan { + interceptor_name: String, + source_id: String, + timeout: Duration, + client: GatewayInterceptorClient, +} + +impl std::fmt::Debug for ProfileSourcePlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProfileSourcePlan") + .field("interceptor_name", &self.interceptor_name) + .field("source_id", &self.source_id) + .field("timeout", &self.timeout) + .finish_non_exhaustive() + } } /// Return `None` when no interceptors are configured. @@ -251,8 +265,8 @@ impl GatewayInterceptorRuntime { let descriptors = ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); - let mut profile_catalog_sources = Vec::new(); - let mut profile_catalog_source_ids = BTreeSet::new(); + let mut profile_sources = Vec::new(); + let mut profile_source_ids = BTreeSet::new(); for config in configs { validate_service_config(&config)?; @@ -335,41 +349,82 @@ impl GatewayInterceptorRuntime { } } - if let Some(source) = - normalize_profile_catalog_source(&config.name, manifest.provider_profile_catalog)? - { - if !profile_catalog_source_ids.insert(source.source_id.clone()) { + if manifest.provider_profiles { + let source_id = format!("interceptor/{}", config.name); + if !profile_source_ids.insert(source_id.clone()) { return Err(InterceptorError::Config(format!( - "duplicate provider profile catalog source id '{}'", - source.source_id + "duplicate provider profile source id '{source_id}'" ))); } - profile_catalog_sources.push(source); + profile_sources.push(ProfileSourcePlan { + interceptor_name: config.name.clone(), + source_id, + timeout, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }); } } let count: usize = bindings.values().map(Vec::len).sum(); info!( bindings = count, - profile_catalog_sources = profile_catalog_sources.len(), + profile_sources = profile_sources.len(), "gateway interceptors initialized" ); Ok(Self { bindings: Arc::new(bindings), - profile_catalog_sources: Arc::new(profile_catalog_sources), + profile_sources: Arc::new(profile_sources), routes: Arc::new(routes), descriptors: Arc::new(descriptors), }) } #[must_use] - pub fn profile_catalog_sources(&self) -> &[ProfileCatalogSource] { - self.profile_catalog_sources.as_ref() + pub fn has_profile_sources(&self) -> bool { + !self.profile_sources.is_empty() + } + + pub async fn provider_profile_snapshots(&self) -> Result> { + let mut snapshots = Vec::with_capacity(self.profile_sources.len()); + for source in self.profile_sources.iter() { + let mut client = source.client.clone(); + let response = tokio::time::timeout( + source.timeout, + client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), + ) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles timed out for '{}'", + source.interceptor_name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles failed for '{}': {status}", + source.interceptor_name + )) + })? + .into_inner(); + + let revision = if response.revision.trim().is_empty() { + provider_profile_snapshot_revision(&response.profiles) + } else { + response.revision + }; + snapshots.push(ProviderProfileSourceSnapshot { + source_id: source.source_id.clone(), + revision, + profiles: response.profiles, + }); + } + Ok(snapshots) } #[must_use] pub fn is_empty(&self) -> bool { - self.bindings.is_empty() + self.bindings.is_empty() && self.profile_sources.is_empty() } #[must_use] @@ -641,57 +696,6 @@ fn normalize_binding( })) } -fn normalize_profile_catalog_source( - interceptor_name: &str, - catalog: Option, -) -> Result> { - let Some(catalog) = catalog else { - return Ok(None); - }; - - let source_id = catalog.source_id.trim(); - if source_id.is_empty() { - return Err(InterceptorError::Config(format!( - "interceptor '{interceptor_name}' provider profile catalog source_id must not be empty" - ))); - } - - let mode = - openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::try_from( - catalog.mode, - ) - .map_err(|_| { - InterceptorError::Config(format!( - "interceptor '{interceptor_name}' provider profile catalog has unknown mode" - )) - })?; - let mode = match mode { - openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Append => { - ProfileCatalogMode::Append - } - openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Authoritative => { - ProfileCatalogMode::Authoritative - } - openshell_core::proto::gateway_interceptor::v1::ProviderProfileCatalogMode::Unspecified => { - return Err(InterceptorError::Config(format!( - "interceptor '{interceptor_name}' provider profile catalog mode must not be unspecified" - ))); - } - }; - - if mode == ProfileCatalogMode::Authoritative && catalog.profiles.is_empty() { - return Err(InterceptorError::Config(format!( - "interceptor '{interceptor_name}' authoritative provider profile catalog must not be empty" - ))); - } - - Ok(Some(ProfileCatalogSource { - source_id: source_id.to_string(), - mode, - profiles: catalog.profiles, - })) -} - fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { let selector = selector .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; @@ -709,6 +713,17 @@ fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = sha2::Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + fn override_selector( override_cfg: &GatewayInterceptorBindingOverride, ) -> Result> { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e4b748dac0..b606d339d6 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -14,7 +14,7 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; use crate::policy_store::PolicyStoreExt; -use crate::provider_profile_catalog::ProviderProfileCatalog; +use crate::provider_profile_sources::ProviderProfileSources; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; @@ -520,7 +520,7 @@ fn run_prover_findings( /// with the merged policy the prover validates against. async fn build_credential_set_for_sandbox_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); @@ -1016,7 +1016,7 @@ async fn current_effective_policy_for_sandbox( .unwrap_or_default(); let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider_names, ) .await?; @@ -1289,7 +1289,7 @@ pub(super) async fn handle_get_sandbox_config( { let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &sandbox_provider_names, ) .await?; @@ -1304,7 +1304,7 @@ pub(super) async fn handle_get_sandbox_config( let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &sandbox_provider_names, ) .await?; @@ -1328,7 +1328,7 @@ async fn compute_provider_env_revision( ) -> Result { compute_provider_env_revision_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider_names, ) .await @@ -1336,7 +1336,7 @@ async fn compute_provider_env_revision( pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1387,7 +1387,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( async fn hash_provider_profile_revision( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_type: &str, hasher: &mut Sha256, ) -> Result<(), Status> { @@ -1404,7 +1404,7 @@ async fn profile_provider_policy_layers( ) -> Result, Status> { profile_provider_policy_layers_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider_names, ) .await @@ -1412,7 +1412,7 @@ async fn profile_provider_policy_layers( async fn profile_provider_policy_layers_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); @@ -1492,13 +1492,13 @@ pub(super) async fn handle_get_sandbox_provider_environment( let provider_names = spec.providers; let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider_names, ) .await?; let provider_environment = super::provider::resolve_provider_environment_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider_names, ) .await?; @@ -2410,7 +2410,7 @@ pub(super) async fn handle_submit_policy_analysis( .unwrap_or_default(); let credential_set = build_credential_set_for_sandbox_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider_names_for_creds, ) .await?; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 8389340c0d..24673efae0 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -8,8 +8,8 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; -use crate::provider_profile_catalog::{ - ProviderProfileCatalog, profile_response_payload, profile_storage_payload, +use crate::provider_profile_sources::{ + ProviderProfileSources, profile_response_payload, profile_storage_payload, stored_profile_resource_version, stored_provider_profile, }; use openshell_core::proto::{ @@ -74,7 +74,7 @@ pub(super) async fn create_provider_record( ) -> Result { create_provider_record_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider, ) .await @@ -82,7 +82,7 @@ pub(super) async fn create_provider_record( pub(super) async fn create_provider_record_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -198,7 +198,7 @@ pub(super) async fn update_provider_record( ) -> Result { update_provider_record_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider, ) .await @@ -206,7 +206,7 @@ pub(super) async fn update_provider_record( pub(super) async fn update_provider_record_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -471,7 +471,7 @@ pub(super) async fn resolve_provider_environment( ) -> Result { resolve_provider_environment_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider_names, ) .await @@ -479,7 +479,7 @@ pub(super) async fn resolve_provider_environment( pub(super) async fn resolve_provider_environment_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result { if provider_names.is_empty() { @@ -559,7 +559,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( /// host, port, endpoint path, and provider credential identity. pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -876,7 +876,7 @@ pub async fn validate_provider_environment_keys_unique( ) -> Result<(), Status> { validate_provider_environment_keys_unique_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider_names, ) .await @@ -884,7 +884,7 @@ pub async fn validate_provider_environment_keys_unique( pub async fn validate_provider_environment_keys_unique_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( @@ -899,7 +899,7 @@ pub async fn validate_provider_environment_keys_unique_with_catalog( pub async fn validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -919,7 +919,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( ) -> Result<(), Status> { validate_provider_update_against_attached_sandboxes_with_catalog( store, - &ProviderProfileCatalog::with_builtin_profiles(), + &ProviderProfileSources::with_default_sources(), provider, ) .await @@ -927,7 +927,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); @@ -956,7 +956,7 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( async fn validate_provider_environment_keys_unique_at( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -1007,7 +1007,7 @@ struct DynamicTokenGrantBinding { async fn dynamic_token_grant_bindings_for_provider_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider: &Provider, ) -> Result, Status> { let provider_name = provider.object_name().to_string(); @@ -1255,7 +1255,7 @@ pub(super) async fn handle_create_provider( let provider_type = provider.r#type.clone(); let result = create_provider_record_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, provider, ) .await; @@ -1312,7 +1312,7 @@ pub(super) async fn handle_list_provider_profiles( let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; let profiles = state - .provider_profile_catalog + .provider_profile_sources .list_profiles(state.store.as_ref()) .await? .into_iter() @@ -1330,7 +1330,7 @@ pub(super) async fn handle_get_provider_profile( let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; let profile = state - .provider_profile_catalog + .provider_profile_sources .get_profile(state.store.as_ref(), &id) .await? .ok_or_else(|| Status::not_found("provider profile not found"))?; @@ -1351,7 +1351,7 @@ pub(super) async fn handle_import_provider_profiles( diagnostics.extend( profile_conflict_diagnostics( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &profiles, ) .await?, @@ -1361,7 +1361,7 @@ pub(super) async fn handle_import_provider_profiles( diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &profiles, "import", ) @@ -1421,7 +1421,7 @@ pub(super) async fn handle_update_provider_profiles( diagnostics.extend( profile_update_target_diagnostics( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &profiles, &target_id, ) @@ -1455,7 +1455,7 @@ pub(super) async fn handle_update_provider_profiles( diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &profiles, "update", ) @@ -1536,7 +1536,7 @@ pub(super) async fn handle_lint_provider_profiles( diagnostics.extend( profile_conflict_diagnostics( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &profiles, ) .await?, @@ -1557,8 +1557,9 @@ pub(super) async fn handle_delete_provider_profile( let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; if let Some(source_id) = state - .provider_profile_catalog - .static_source_for_profile(&id) + .provider_profile_sources + .static_source_for_profile(state.store.as_ref(), &id) + .await? { return Err(Status::failed_precondition(format!( "provider profile '{id}' is managed by source '{source_id}' and cannot be deleted" @@ -1594,7 +1595,7 @@ pub(super) async fn handle_delete_provider_profile( pub(super) async fn get_provider_type_profile_with_catalog( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, id: &str, ) -> Result, Status> { let Some(id) = normalize_profile_id(id) else { @@ -1605,7 +1606,7 @@ pub(super) async fn get_provider_type_profile_with_catalog( async fn provider_refresh_defaults( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider: &Provider, credential_key: &str, ) -> Result, Status> { @@ -1654,7 +1655,7 @@ fn validate_refresh_material( async fn provider_type_allows_empty_credentials( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, provider_type: &str, ) -> Result { let Some(profile) = @@ -1716,7 +1717,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1724,7 +1725,7 @@ async fn profile_conflict_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if let Some(source_id) = catalog.static_source_for_profile(&id) { + if let Some(source_id) = catalog.static_source_for_profile(store, &id).await? { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), @@ -1756,7 +1757,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1777,7 +1778,7 @@ async fn profile_update_target_diagnostics( }); } } - if let Some(source_id) = catalog.static_source_for_profile(target_id) { + if let Some(source_id) = catalog.static_source_for_profile(store, target_id).await? { diagnostics.push(ProfileValidationDiagnostic { source: target_id.to_string(), profile_id: target_id.to_string(), @@ -1807,7 +1808,7 @@ async fn profile_update_target_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if let Some(source_id) = catalog.static_source_for_profile(&id) { + if let Some(source_id) = catalog.static_source_for_profile(store, &id).await? { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), @@ -1824,7 +1825,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, - catalog: &ProviderProfileCatalog, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1975,7 +1976,7 @@ pub(super) async fn handle_update_provider( .extend(req.credential_expires_at_ms); let result = update_provider_record_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, provider, ) .await; @@ -2134,14 +2135,14 @@ pub(super) async fn handle_configure_provider_refresh( .ok_or_else(|| Status::not_found("provider not found"))?; validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider, credential_key, ) .await?; let refresh_defaults = provider_refresh_defaults( state.store.as_ref(), - &state.provider_profile_catalog, + &state.provider_profile_sources, &provider, credential_key, ) diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 90d81eeb00..7a7a67696f 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -35,7 +35,7 @@ mod inference; mod multiplex; mod persistence; pub(crate) mod policy_store; -mod provider_profile_catalog; +mod provider_profile_sources; mod provider_refresh; mod readiness; mod sandbox_index; @@ -153,9 +153,9 @@ pub struct ServerState { pub(crate) gateway_interceptors: Option, - /// Gateway-local provider profile catalog assembled from built-ins and - /// profile-vending interceptors. User-imported profiles are read on demand. - pub(crate) provider_profile_catalog: provider_profile_catalog::ProviderProfileCatalog, + /// Gateway-local provider profile sources. User-imported profiles are read + /// on demand when the user source is configured. + pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -207,8 +207,8 @@ impl ServerState { k8s_sa_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, - provider_profile_catalog: - provider_profile_catalog::ProviderProfileCatalog::with_builtin_profiles(), + provider_profile_sources: + provider_profile_sources::ProviderProfileSources::with_default_sources(), } } } @@ -281,17 +281,10 @@ pub(crate) async fn run_server( .map_err(|e| { Error::config(format!("gateway interceptor initialization failed: {e}")) })?; - let provider_profile_catalog = - provider_profile_catalog::ProviderProfileCatalog::from_interceptor_sources( - gateway_interceptors - .as_ref() - .map_or(&[][..], |runtime| runtime.profile_catalog_sources()), - ) - .map_err(|e| { - Error::config(format!( - "provider profile catalog initialization failed: {e}" - )) - })?; + let provider_profile_sources = + provider_profile_sources::ProviderProfileSources::from_gateway_interceptors( + gateway_interceptors.clone(), + ); let mut state = ServerState::new( config.clone(), store.clone(), @@ -303,7 +296,7 @@ pub(crate) async fn run_server( oidc_cache, ); state.gateway_interceptors = gateway_interceptors; - state.provider_profile_catalog = provider_profile_catalog; + state.provider_profile_sources = provider_profile_sources; // 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/provider_profile_catalog.rs b/crates/openshell-server/src/provider_profile_catalog.rs deleted file mode 100644 index cd62fa4484..0000000000 --- a/crates/openshell-server/src/provider_profile_catalog.rs +++ /dev/null @@ -1,394 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Gateway-local provider profile catalog. - -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::Arc; - -use openshell_core::proto::{ProviderProfile, StoredProviderProfile}; -use openshell_providers::{ - ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, - validate_profile_set, -}; -use prost::Message as _; -use sha2::{Digest, Sha256}; -use tonic::Status; - -use crate::persistence::{ObjectType, Store}; - -const BUILTIN_SOURCE_ID: &str = "builtin"; - -impl ObjectType for StoredProviderProfile { - fn object_type() -> &'static str { - "provider_profile" - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProfileCatalogSourceMode { - Append, - Authoritative, -} - -impl ProfileCatalogSourceMode { - const fn as_revision_tag(self) -> &'static [u8] { - match self { - Self::Append => b"append", - Self::Authoritative => b"authoritative", - } - } -} - -#[derive(Debug, Clone)] -pub struct ProfileCatalogSource { - pub source_id: String, - pub mode: ProfileCatalogSourceMode, - pub profiles: Vec, -} - -#[derive(Debug, Clone)] -struct StaticProfileEntry { - source_id: String, - mode: ProfileCatalogSourceMode, - profile: ProviderTypeProfile, -} - -#[derive(Debug, Clone)] -pub struct ProviderProfileCatalog { - visible_static: Arc>, - authoritative: bool, -} - -impl ProviderProfileCatalog { - pub fn from_interceptor_sources( - sources: &[openshell_gateway_interceptors::ProfileCatalogSource], - ) -> Result { - let mut catalog_sources = vec![builtin_profile_source()]; - for source in sources { - catalog_sources.push(ProfileCatalogSource { - source_id: source.source_id.clone(), - mode: match source.mode { - openshell_gateway_interceptors::ProfileCatalogMode::Append => { - ProfileCatalogSourceMode::Append - } - openshell_gateway_interceptors::ProfileCatalogMode::Authoritative => { - ProfileCatalogSourceMode::Authoritative - } - }, - profiles: source - .profiles - .iter() - .map(ProviderTypeProfile::from_proto) - .collect(), - }); - } - Self::from_sources(catalog_sources) - } - - pub fn with_builtin_profiles() -> Self { - Self::from_sources(vec![builtin_profile_source()]) - .expect("built-in provider profiles must form a valid catalog") - } - - pub fn from_sources(sources: Vec) -> Result { - let mut source_ids = BTreeSet::new(); - let authoritative_count = sources - .iter() - .filter(|source| source.mode == ProfileCatalogSourceMode::Authoritative) - .count(); - if authoritative_count > 1 { - return Err( - "multiple authoritative provider profile catalog sources configured".into(), - ); - } - - for source in &sources { - let source_id = source.source_id.trim(); - if source_id.is_empty() { - return Err("provider profile catalog source_id must not be empty".into()); - } - if !source_ids.insert(source_id.to_string()) { - return Err(format!( - "duplicate provider profile catalog source id '{source_id}'" - )); - } - if source.mode == ProfileCatalogSourceMode::Authoritative && source.profiles.is_empty() - { - return Err(format!( - "authoritative provider profile catalog source '{source_id}' must not be empty" - )); - } - validate_source_profiles(source)?; - } - - let authoritative = authoritative_count == 1; - let mut visible_static = BTreeMap::new(); - for source in sources.iter().filter(|source| { - if authoritative { - source.mode == ProfileCatalogSourceMode::Authoritative - } else { - source.mode == ProfileCatalogSourceMode::Append - } - }) { - for profile in &source.profiles { - let id = normalize_profile_id(&profile.id).ok_or_else(|| { - format!( - "provider profile '{}' in source '{}' has invalid id", - profile.id, source.source_id - ) - })?; - if visible_static - .insert( - id.clone(), - StaticProfileEntry { - source_id: source.source_id.clone(), - mode: source.mode, - profile: profile.clone(), - }, - ) - .is_some() - { - return Err(format!( - "duplicate visible provider profile id '{id}' across catalog sources" - )); - } - } - } - - Ok(Self { - visible_static: Arc::new(visible_static), - authoritative, - }) - } - - #[must_use] - pub fn static_source_for_profile(&self, id: &str) -> Option<&str> { - let id = normalize_profile_id(id)?; - self.visible_static - .get(&id) - .map(|entry| entry.source_id.as_str()) - } - - pub async fn list_profiles(&self, store: &Store) -> Result, Status> { - let mut profiles = self - .visible_static - .values() - .map(|entry| entry.profile.to_proto()) - .collect::>(); - if !self.authoritative { - for stored in user_provider_profiles(store).await? { - let resource_version = stored_profile_resource_version(&stored); - if let Some(profile) = stored.profile { - if normalize_profile_id(&profile.id) - .is_some_and(|id| self.visible_static.contains_key(&id)) - { - continue; - } - profiles.push(profile_response_payload(profile, resource_version)); - } - } - } - profiles.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(profiles) - } - - pub async fn get_profile( - &self, - store: &Store, - id: &str, - ) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - if let Some(entry) = self.visible_static.get(&id) { - return Ok(Some(entry.profile.to_proto())); - } - if self.authoritative { - return Ok(None); - } - let profile = store - .get_message_by_name::(&id) - .await - .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? - .and_then(|stored| { - let resource_version = stored_profile_resource_version(&stored); - stored - .profile - .map(|profile| profile_response_payload(profile, resource_version)) - }); - Ok(profile) - } - - pub async fn get_type_profile( - &self, - store: &Store, - id: &str, - ) -> Result, Status> { - Ok(self - .get_profile(store, id) - .await? - .as_ref() - .map(ProviderTypeProfile::from_proto)) - } - - pub async fn hash_profile_revision( - &self, - store: &Store, - profile_id: &str, - hasher: &mut Sha256, - ) -> Result<(), Status> { - let Some(profile_id) = normalize_profile_id(profile_id) else { - hasher.update(b"invalid-profile-id"); - return Ok(()); - }; - - if let Some(entry) = self.visible_static.get(&profile_id) { - hasher.update(b"catalog-profile"); - hasher.update(entry.source_id.as_bytes()); - hasher.update(entry.mode.as_revision_tag()); - hasher.update(entry.profile.to_proto().encode_to_vec()); - return Ok(()); - } - - if self.authoritative { - hasher.update(b"missing"); - return Ok(()); - } - - hasher.update(b"user-profile"); - match store - .get_by_name(StoredProviderProfile::object_type(), &profile_id) - .await - .map_err(|e| { - Status::internal(format!("fetch provider profile '{profile_id}' failed: {e}")) - })? { - Some(record) => { - hasher.update(record.id.as_bytes()); - hasher.update(record.updated_at_ms.to_le_bytes()); - hasher.update(record.payload.as_slice()); - } - None => { - hasher.update(b"missing"); - } - } - Ok(()) - } -} - -fn builtin_profile_source() -> ProfileCatalogSource { - ProfileCatalogSource { - source_id: BUILTIN_SOURCE_ID.to_string(), - mode: ProfileCatalogSourceMode::Append, - profiles: builtin_profiles().to_vec(), - } -} - -fn validate_source_profiles(source: &ProfileCatalogSource) -> Result<(), String> { - let profiles = source - .profiles - .iter() - .map(|profile| (source.source_id.clone(), profile.clone())) - .collect::>(); - let diagnostics = validate_profile_set(&profiles); - if let Some(diagnostic) = diagnostics - .into_iter() - .find(|diagnostic| diagnostic.severity == "error") - { - return Err(format_diagnostic(diagnostic)); - } - Ok(()) -} - -fn format_diagnostic(diagnostic: ProfileValidationDiagnostic) -> String { - if diagnostic.profile_id.is_empty() { - format!("{}: {}", diagnostic.field, diagnostic.message) - } else { - format!( - "provider profile '{}' {}: {}", - diagnostic.profile_id, diagnostic.field, diagnostic.message - ) - } -} - -pub(crate) async fn user_provider_profiles( - store: &Store, -) -> Result, Status> { - let profiles: Vec = store - .list_messages(10_000, 0) - .await - .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; - Ok(profiles) -} - -pub(crate) fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { - use crate::persistence::current_time_ms; - let now_ms = current_time_ms(); - let profile = profile_storage_payload(profile); - StoredProviderProfile { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: uuid::Uuid::new_v4().to_string(), - name: profile.id.clone(), - created_at_ms: now_ms, - labels: std::collections::HashMap::new(), - resource_version: 0, - annotations: std::collections::HashMap::new(), - }), - profile: Some(profile), - } -} - -pub(crate) fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { - profile.resource_version = 0; - profile -} - -pub(crate) fn profile_response_payload( - mut profile: ProviderProfile, - resource_version: u64, -) -> ProviderProfile { - profile.resource_version = resource_version; - profile -} - -pub(crate) fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { - stored - .metadata - .as_ref() - .map_or(0, |metadata| metadata.resource_version) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn profile(id: &str) -> ProviderTypeProfile { - let mut profile = builtin_profiles() - .iter() - .find(|profile| profile.id == "github") - .expect("github built-in profile") - .clone(); - profile.id = id.to_string(); - profile.display_name = id.to_string(); - profile - } - - #[test] - fn authoritative_catalog_hides_builtin_sources_from_management_checks() { - let catalog = ProviderProfileCatalog::from_sources(vec![ - builtin_profile_source(), - ProfileCatalogSource { - source_id: "interceptor/test".to_string(), - mode: ProfileCatalogSourceMode::Authoritative, - profiles: vec![profile("slack")], - }, - ]) - .unwrap(); - - assert_eq!( - catalog.static_source_for_profile("slack"), - Some("interceptor/test") - ); - assert_eq!(catalog.static_source_for_profile("github"), None); - } -} diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs new file mode 100644 index 0000000000..6b0f09cf81 --- /dev/null +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -0,0 +1,472 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-local provider profile sources. + +use std::collections::{BTreeMap, BTreeSet}; + +use async_trait::async_trait; +use openshell_core::proto::{ProviderProfile, StoredProviderProfile}; +use openshell_gateway_interceptors::ProviderProfileSourceSnapshot as InterceptorProfileSnapshot; +use openshell_providers::{ + ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, + validate_profile_set, +}; +use prost::Message as _; +use sha2::{Digest, Sha256}; +use tonic::Status; + +use crate::persistence::{ObjectType, Store}; + +const BUILTIN_SOURCE_ID: &str = "builtin"; +const USER_SOURCE_ID: &str = "user"; + +impl ObjectType for StoredProviderProfile { + fn object_type() -> &'static str { + "provider_profile" + } +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileSnapshot { + source_id: String, + revision: String, + profiles: Vec, + user_managed: bool, + allow_empty: bool, +} + +#[async_trait] +pub trait ProviderProfileSource: Send + Sync { + async fn snapshot(&self, store: &Store) -> Result; +} + +#[derive(Debug, Clone, Default)] +struct BuiltinProviderProfileSource; + +#[async_trait] +impl ProviderProfileSource for BuiltinProviderProfileSource { + async fn snapshot(&self, _store: &Store) -> Result { + let profiles = builtin_profiles() + .iter() + .map(ProviderTypeProfile::to_proto) + .collect::>(); + Ok(ProviderProfileSnapshot { + source_id: BUILTIN_SOURCE_ID.to_string(), + revision: profile_snapshot_revision(&profiles), + profiles, + user_managed: false, + allow_empty: false, + }) + } +} + +#[derive(Debug, Clone, Default)] +struct UserProviderProfileSource; + +#[async_trait] +impl ProviderProfileSource for UserProviderProfileSource { + async fn snapshot(&self, store: &Store) -> Result { + let stored = user_provider_profiles(store).await?; + let mut profiles = Vec::new(); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-user-provider-profile-source-v1"); + for stored in stored { + let resource_version = stored_profile_resource_version(&stored); + hasher.update(resource_version.to_le_bytes()); + if let Some(profile) = stored.profile { + let profile = profile_response_payload(profile, resource_version); + hasher.update(profile.encode_to_vec()); + profiles.push(profile); + } + } + Ok(ProviderProfileSnapshot { + source_id: USER_SOURCE_ID.to_string(), + revision: format!("sha256:{:x}", hasher.finalize()), + profiles, + user_managed: true, + allow_empty: true, + }) + } +} + +#[derive(Debug, Clone)] +enum ConfiguredProviderProfileSource { + Builtin(BuiltinProviderProfileSource), + User(UserProviderProfileSource), + Interceptors(openshell_gateway_interceptors::GatewayInterceptorRuntime), +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileSources { + sources: Vec, +} + +#[derive(Debug, Clone)] +struct EffectiveProfileEntry { + source_id: String, + source_revision: String, + user_managed: bool, + profile: ProviderTypeProfile, + response: ProviderProfile, +} + +#[derive(Debug, Clone)] +struct EffectiveProviderProfiles { + profiles: BTreeMap, +} + +impl ProviderProfileSources { + pub fn with_default_sources() -> Self { + Self { + sources: vec![ + ConfiguredProviderProfileSource::Builtin(BuiltinProviderProfileSource), + ConfiguredProviderProfileSource::User(UserProviderProfileSource), + ], + } + } + + pub fn from_gateway_interceptors( + runtime: Option, + ) -> Self { + if let Some(runtime) = runtime + && runtime.has_profile_sources() + { + return Self { + sources: vec![ConfiguredProviderProfileSource::Interceptors(runtime)], + }; + } + Self::with_default_sources() + } + + pub async fn list_profiles(&self, store: &Store) -> Result, Status> { + let catalog = self.effective_profiles(store).await?; + Ok(catalog + .profiles + .values() + .map(|entry| entry.response.clone()) + .collect()) + } + + pub async fn get_profile( + &self, + store: &Store, + id: &str, + ) -> Result, Status> { + let Some(id) = normalize_profile_id(id) else { + return Ok(None); + }; + Ok(self + .effective_profiles(store) + .await? + .profiles + .get(&id) + .map(|entry| entry.response.clone())) + } + + pub async fn get_type_profile( + &self, + store: &Store, + id: &str, + ) -> Result, Status> { + let Some(id) = normalize_profile_id(id) else { + return Ok(None); + }; + Ok(self + .effective_profiles(store) + .await? + .profiles + .get(&id) + .map(|entry| entry.profile.clone())) + } + + pub async fn static_source_for_profile( + &self, + store: &Store, + id: &str, + ) -> Result, Status> { + let Some(id) = normalize_profile_id(id) else { + return Ok(None); + }; + Ok(self + .effective_profiles(store) + .await? + .profiles + .get(&id) + .filter(|entry| !entry.user_managed) + .map(|entry| entry.source_id.clone())) + } + + pub async fn hash_profile_revision( + &self, + store: &Store, + profile_id: &str, + hasher: &mut Sha256, + ) -> Result<(), Status> { + let Some(profile_id) = normalize_profile_id(profile_id) else { + hasher.update(b"invalid-profile-id"); + return Ok(()); + }; + + let catalog = self.effective_profiles(store).await?; + let Some(entry) = catalog.profiles.get(&profile_id) else { + hasher.update(b"missing"); + return Ok(()); + }; + + hasher.update(b"provider-profile-source-entry"); + hasher.update(entry.source_id.as_bytes()); + hasher.update(entry.source_revision.as_bytes()); + let ownership_tag: &[u8] = if entry.user_managed { + b"user-managed" + } else { + b"source-managed" + }; + hasher.update(ownership_tag); + hasher.update(entry.response.encode_to_vec()); + Ok(()) + } + + async fn effective_profiles(&self, store: &Store) -> Result { + let snapshots = self.snapshots(store).await?; + build_effective_profiles(snapshots) + } + + async fn snapshots(&self, store: &Store) -> Result, Status> { + let mut snapshots = Vec::new(); + for source in &self.sources { + match source { + ConfiguredProviderProfileSource::Builtin(source) => { + snapshots.push(source.snapshot(store).await?); + } + ConfiguredProviderProfileSource::User(source) => { + snapshots.push(source.snapshot(store).await?); + } + ConfiguredProviderProfileSource::Interceptors(runtime) => { + let external = runtime.provider_profile_snapshots().await.map_err(|err| { + Status::unavailable(format!( + "provider profile source snapshot failed: {err}" + )) + })?; + snapshots.extend(external.into_iter().map(interceptor_snapshot)); + } + } + } + Ok(snapshots) + } +} + +fn interceptor_snapshot(snapshot: InterceptorProfileSnapshot) -> ProviderProfileSnapshot { + ProviderProfileSnapshot { + source_id: snapshot.source_id, + revision: snapshot.revision, + profiles: snapshot.profiles, + user_managed: false, + allow_empty: false, + } +} + +fn build_effective_profiles( + snapshots: Vec, +) -> Result { + let mut source_ids = BTreeSet::new(); + let mut profiles = BTreeMap::new(); + + for snapshot in snapshots { + let source_id = snapshot.source_id.trim(); + if source_id.is_empty() { + return Err(Status::failed_precondition( + "provider profile source id must not be empty", + )); + } + if !source_ids.insert(source_id.to_string()) { + return Err(Status::failed_precondition(format!( + "duplicate provider profile source id '{source_id}'" + ))); + } + if snapshot.profiles.is_empty() && !snapshot.allow_empty { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' returned no profiles" + ))); + } + + let source_profiles = snapshot + .profiles + .iter() + .map(|profile| { + ( + source_id.to_string(), + ProviderTypeProfile::from_proto(profile), + ) + }) + .collect::>(); + validate_source_profiles(source_id, &source_profiles)?; + + for profile in snapshot.profiles { + let id = normalize_profile_id(&profile.id).ok_or_else(|| { + Status::failed_precondition(format!( + "provider profile '{}' in source '{}' has invalid id", + profile.id, source_id + )) + })?; + if profiles.contains_key(&id) { + return Err(Status::failed_precondition(format!( + "duplicate provider profile id '{id}' across configured sources" + ))); + } + profiles.insert( + id, + EffectiveProfileEntry { + source_id: source_id.to_string(), + source_revision: snapshot.revision.clone(), + user_managed: snapshot.user_managed, + profile: ProviderTypeProfile::from_proto(&profile), + response: profile, + }, + ); + } + } + + Ok(EffectiveProviderProfiles { profiles }) +} + +fn validate_source_profiles( + source_id: &str, + profiles: &[(String, ProviderTypeProfile)], +) -> Result<(), Status> { + let diagnostics = validate_profile_set(profiles); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == "error") + { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {}", + format_diagnostic(diagnostic) + ))); + } + Ok(()) +} + +fn format_diagnostic(diagnostic: ProfileValidationDiagnostic) -> String { + if diagnostic.profile_id.is_empty() { + format!("{}: {}", diagnostic.field, diagnostic.message) + } else { + format!( + "provider profile '{}' {}: {}", + diagnostic.profile_id, diagnostic.field, diagnostic.message + ) + } +} + +fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +pub(crate) async fn user_provider_profiles( + store: &Store, +) -> Result, Status> { + let profiles: Vec = store + .list_messages(10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + Ok(profiles) +} + +pub(crate) fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + use crate::persistence::current_time_ms; + let now_ms = current_time_ms(); + let profile = profile_storage_payload(profile); + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: profile.id.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: std::collections::HashMap::new(), + }), + profile: Some(profile), + } +} + +pub(crate) fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { + profile.resource_version = 0; + profile +} + +pub(crate) fn profile_response_payload( + mut profile: ProviderProfile, + resource_version: u64, +) -> ProviderProfile { + profile.resource_version = resource_version; + profile +} + +pub(crate) fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { + stored + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile(id: &str) -> ProviderProfile { + let mut profile = builtin_profiles() + .iter() + .find(|profile| profile.id == "github") + .expect("github built-in profile") + .clone(); + profile.id = id.to_string(); + profile.display_name = id.to_string(); + profile.to_proto() + } + + #[test] + fn duplicate_profile_ids_across_sources_are_invalid() { + let err = build_effective_profiles(vec![ + ProviderProfileSnapshot { + source_id: "source-a".to_string(), + revision: "a".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }, + ProviderProfileSnapshot { + source_id: "source-b".to_string(), + revision: "b".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }, + ]) + .unwrap_err(); + + assert!(err.message().contains("duplicate provider profile id")); + } + + #[test] + fn source_managed_profiles_report_static_source() { + let catalog = build_effective_profiles(vec![ProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "test".to_string(), + profiles: vec![profile("slack")], + user_managed: false, + allow_empty: false, + }]) + .unwrap(); + + let entry = catalog.profiles.get("slack").unwrap(); + assert_eq!(entry.source_id, "interceptor/test"); + assert!(!entry.user_managed); + } +} diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 137db87eb6..a54a65891c 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -890,6 +890,7 @@ dependencies = [ "openshell-core", "openshell-policy", "openshell-providers", + "prost", "prost-types", "rcgen", "serde", @@ -1558,9 +1559,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" dependencies = [ "deranged", "num-conv", @@ -1578,9 +1579,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index aae6f8c698..287f442d1f 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -16,6 +16,7 @@ jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } openshell-policy = { path = "../../crates/openshell-policy" } openshell-providers = { path = "../../crates/openshell-providers" } +prost = "0.14" prost-types = "0.14" rcgen = { version = "0.13", features = ["crypto", "pem"] } serde = { version = "1", features = ["derive"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index f474e3c5a8..f06c56eef1 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -1,24 +1,22 @@ # Governance Interceptor Example This standalone example implements the -`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates how to -extend OpenShell to provide advanced governance over sandbox policies. +`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates +how an interceptor can vend provider profiles and make them the gateway's +authoritative profile source. -- every new sandbox receives `policy.yaml` sourced from this examples folder -- 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 +- provider profile YAML lives in `profiles/*.yaml` +- `provider list-profiles` shows only the profiles vended by this interceptor +- providers can only be created with a `type` that matches one of those vended + profile IDs +- every new sandbox receives `policy.yaml` and the vended provider set during + `CreateSandbox` +- every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata + annotation that is used to verify the policy +- sandbox creation evaluations add a `correlation_id` log annotation for gateway + audit logs, plus non-secret policy hash/signing key metadata - users cannot replace or merge sandbox policy after sandbox creation -- 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 outside the governed set +- users cannot import or update provider profiles outside the vended set - provider profile deletion is blocked by the interceptor Run the interceptor: @@ -33,9 +31,12 @@ cargo run -- \ 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. +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. Provider profile YAML files are loaded by the interceptor from `--profiles` (default: this example's `profiles/` directory). The interceptor names each @@ -43,25 +44,14 @@ 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. -The interceptor vends the loaded profiles through -`InterceptorManifest.provider_profile_catalog` with authoritative mode. While -the interceptor is attached, the gateway treats that catalog as the profile -source of truth: `provider list-profiles` shows only `github` and `slack`, and -the built-in/user profile catalog is hidden. The normal import, update, and -delete profile APIs remain available for gateways without an authoritative -catalog, but profiles managed by this interceptor cannot be changed through -those APIs. - -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. +The interceptor advertises `provider_profiles = true` in its manifest and vends +the current profile set through `SnapshotProviderProfiles`. While the +interceptor is attached, the gateway uses that snapshot as the profile source: +`provider list-profiles` shows only `github` and `slack`, and built-in/user +sources are omitted. Valid edits to files under `profiles/` change the snapshot +revision, so running sandboxes that use the edited provider profile reload their +effective provider-derived policy through the normal gateway config polling +path. Invalid edits keep the last valid snapshot active. Gateway TOML snippet: diff --git a/examples/governance-interceptor/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml index 503967a532..4b90aa7c3b 100644 --- a/examples/governance-interceptor/profiles/github.yaml +++ b/examples/governance-interceptor/profiles/github.yaml @@ -14,7 +14,7 @@ credentials: discovery: credentials: [api_token] endpoints: - - host: api.github.com + - host: api-1.github.com port: 443 protocol: rest access: read-only diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index a2ee578762..c939c0306c 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -377,28 +377,21 @@ EOF 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" - 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 disallowed 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 bitbucket --credential BITBUCKET_TOKEN=dummy - run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true + run_step "creates sandbox with explicit providers" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --provider github --provider slack --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 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 slack --credential SLACK_BOT_TOKEN=changed - expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github } print_ready() { diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 7ba112e22f..28c004fa25 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -13,7 +14,7 @@ use jsonwebtoken::{ use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, - ProviderProfileCatalog, ProviderProfileCatalogMode, + ProviderProfileSnapshot, ProviderProfileSnapshotRequest, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; use openshell_core::proto::{ @@ -22,7 +23,9 @@ use openshell_core::proto::{ }; use openshell_policy::parse_sandbox_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; -use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; +use prost::Message as _; +use prost_types::ListValue; +use prost_types::{Struct, Value as ProtoValue, value::Kind}; use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value, json}; @@ -36,7 +39,6 @@ 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 PROFILE_CATALOG_SOURCE_ID: &str = "interceptor/provider-governance"; #[derive(Clone)] struct PolicySigner { @@ -135,8 +137,15 @@ struct GovernanceInterceptorService { policy_hash: String, policy_signature: String, policy_signer: PolicySigner, - managed_profile_ids: Vec, - managed_profiles: Vec, + profiles_path: Option, + profile_state: Arc>, +} + +#[derive(Clone, Debug)] +struct ProviderProfileState { + ids: Vec, + profiles: Vec, + revision: String, } #[derive(Clone, Debug)] @@ -145,15 +154,25 @@ struct LoadedProviderProfile { } impl GovernanceInterceptorService { - fn from_yaml(policy_yaml: &str, profiles: Vec) -> Result { + #[cfg(test)] + fn from_profiles(profiles: Vec) -> Result { + Self::from_yaml(include_str!("../policy.yaml"), profiles, None) + } + + fn from_policy_and_profiles_path(policy_yaml: &str, path: PathBuf) -> Result { + let profiles = load_provider_profiles(&path)?; + Self::from_yaml(policy_yaml, profiles, Some(path)) + } + + fn from_yaml( + policy_yaml: &str, + profiles: Vec, + profiles_path: Option, + ) -> Result { if profiles.is_empty() { return Err("at least one provider profile must be loaded".to_string()); } - let managed_profile_ids = loaded_profile_ids(&profiles); - let managed_profiles = profiles - .into_iter() - .map(|loaded| loaded.profile) - .collect::>(); + let profile_state = profile_state_from_loaded(profiles); 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); @@ -166,8 +185,8 @@ impl GovernanceInterceptorService { policy_hash, policy_signature, policy_signer, - managed_profile_ids, - managed_profiles, + profiles_path, + profile_state: Arc::new(RwLock::new(profile_state)), }) } @@ -175,11 +194,7 @@ impl GovernanceInterceptorService { InterceptorManifest { name: "provider-governance".to_string(), failure_policy: "fail_closed".to_string(), - provider_profile_catalog: Some(ProviderProfileCatalog { - source_id: PROFILE_CATALOG_SOURCE_ID.to_string(), - mode: ProviderProfileCatalogMode::Authoritative as i32, - profiles: self.managed_profiles.clone(), - }), + provider_profiles: true, bindings: vec![ binding( "govern-create-sandbox", @@ -189,34 +204,14 @@ impl GovernanceInterceptorService { 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", + "govern-update-config", + "UpdateConfig", &[GatewayInterceptorPhase::Validate], ), binding( @@ -242,6 +237,7 @@ impl GovernanceInterceptorService { &self, evaluation: &InterceptorEvaluation, ) -> Result { + let profile_state = self.current_profile_state(); let phase = GatewayInterceptorPhase::try_from(evaluation.phase) .map_err(|_| Status::invalid_argument("unknown interceptor phase"))?; let operation = evaluation @@ -252,32 +248,22 @@ impl GovernanceInterceptorService { match (evaluation.method.as_str(), phase) { ("CreateSandbox", GatewayInterceptorPhase::ModifyOperation) => { - self.patch_create_sandbox(&operation) + self.patch_create_sandbox(&operation, &profile_state.ids) } ("CreateSandbox", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_create_sandbox(&operation)) - } - ( - "AttachSandboxProvider" | "DetachSandboxProvider", - GatewayInterceptorPhase::Validate, - ) => Ok(deny("governed providers are fixed at sandbox creation")), - ("UpdateConfig", GatewayInterceptorPhase::Validate) => { - Ok(validate_update_config(&operation)) + Ok(self.validate_create_sandbox(&operation, &profile_state.ids)) } ("CreateProvider", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_create_provider(&operation)) + Ok(self.validate_create_provider(&operation, &profile_state.ids)) } - ("UpdateProvider", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_update_provider(&operation)) - } - ("DeleteProvider", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_delete_provider(&operation)) + ("UpdateConfig", GatewayInterceptorPhase::Validate) => { + Ok(validate_update_config(&operation, &evaluation.principal)) } ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_import_provider_profiles(&operation)) + Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) } ("UpdateProviderProfiles", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_update_provider_profiles(&operation)) + Ok(self.validate_update_provider_profiles(&operation, &profile_state.ids)) } ("DeleteProviderProfile", GatewayInterceptorPhase::Validate) => { Ok(validate_delete_provider_profile()) @@ -286,14 +272,18 @@ impl GovernanceInterceptorService { } } - fn patch_create_sandbox(&self, operation: &Value) -> Result { + fn patch_create_sandbox( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> 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!(&self.managed_profile_ids), + json!(managed_profile_ids), )?); } else { patches.push(json_patch( @@ -301,7 +291,7 @@ impl GovernanceInterceptorService { "/spec", json!({ "policy": self.policy, - "providers": self.managed_profile_ids, + "providers": managed_profile_ids, }), )?); } @@ -324,7 +314,11 @@ impl GovernanceInterceptorService { Ok(result) } - fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { + fn validate_create_sandbox( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { let Some(policy) = operation.pointer("/spec/policy") else { return deny("sandbox policy must match the provider governance baseline"); }; @@ -350,55 +344,58 @@ impl GovernanceInterceptorService { if sandbox_policy_hash != self.policy_hash || policy != &self.policy { return deny("sandbox policy must match the provider governance baseline"); } - if !providers_are_managed( - operation.pointer("/spec/providers"), - &self.managed_profile_ids, - ) { + if !providers_are_managed(operation.pointer("/spec/providers"), managed_profile_ids) { return deny(&format!( "sandbox providers must be exactly {}", - format_id_list(&self.managed_profile_ids) + format_id_list(managed_profile_ids) )); } allow() } - fn validate_create_provider(&self, operation: &Value) -> InterceptorResult { - let name = provider_name(operation); + fn current_profile_state(&self) -> ProviderProfileState { + if let Some(path) = &self.profiles_path { + match load_provider_profiles(path) { + Ok(profiles) => { + let state = profile_state_from_loaded(profiles); + if let Ok(mut guard) = self.profile_state.write() { + *guard = state.clone(); + } + return state; + } + Err(err) => { + eprintln!( + "failed to reload provider profiles; keeping last valid snapshot: {err}" + ); + } + } + } + self.profile_state + .read() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + fn validate_create_provider( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { let provider_type = provider_type(operation); - if !self.is_managed_profile_id(name) { + if !is_managed_profile_id(managed_profile_ids, provider_type) { return deny(&format!( - "only managed provider records may be created: {}", - format_id_list(&self.managed_profile_ids) + "providers may only use vended provider profiles: {}", + format_id_list(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 { + fn validate_import_provider_profiles( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { let Some(profiles) = operation.get("profiles").and_then(Value::as_array) else { return deny("provider profile imports must include governed profile payloads"); }; @@ -407,25 +404,29 @@ impl GovernanceInterceptorService { } for item in profiles { let id = profile_id_from_import_item(item); - if !self.is_managed_profile_id(id) { + if !is_managed_profile_id(managed_profile_ids, id) { return deny(&format!( "only managed provider profiles may be imported: {}", - format_id_list(&self.managed_profile_ids) + format_id_list(managed_profile_ids) )); } } allow() } - fn validate_update_provider_profiles(&self, operation: &Value) -> InterceptorResult { + fn validate_update_provider_profiles( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { let target_id = operation .get("id") .and_then(Value::as_str) .unwrap_or_default(); - if !self.is_managed_profile_id(target_id) { + if !is_managed_profile_id(managed_profile_ids, target_id) { return deny(&format!( "only managed provider profiles may be updated: {}", - format_id_list(&self.managed_profile_ids) + format_id_list(managed_profile_ids) )); } let payload_id = operation @@ -439,10 +440,6 @@ impl GovernanceInterceptorService { } allow() } - - fn is_managed_profile_id(&self, id: &str) -> bool { - self.managed_profile_ids.iter().any(|managed| managed == id) - } } #[tonic::async_trait] @@ -460,6 +457,17 @@ impl GatewayInterceptor for GovernanceInterceptorService { ) -> Result, Status> { self.evaluate_inner(request.get_ref()).map(Response::new) } + + async fn snapshot_provider_profiles( + &self, + _request: Request, + ) -> Result, Status> { + let state = self.current_profile_state(); + Ok(Response::new(ProviderProfileSnapshot { + revision: state.revision, + profiles: state.profiles, + })) + } } fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> InterceptorBinding { @@ -505,7 +513,17 @@ fn deny(reason: &str) -> InterceptorResult { } } -fn validate_update_config(operation: &Value) -> InterceptorResult { +fn validate_update_config( + operation: &Value, + principal: &HashMap, +) -> InterceptorResult { + if principal.get("kind").is_some_and(|kind| kind == "sandbox") { + return allow(); + } + let is_global = operation + .get("global") + .and_then(Value::as_bool) + .unwrap_or(false); let has_policy = operation .get("policy") .is_some_and(|value| !value.is_null()); @@ -514,8 +532,8 @@ fn validate_update_config(operation: &Value) -> InterceptorResult { .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") + if !is_global && (has_policy || has_merge_operations) { + deny("sandbox policy updates are blocked by provider profile governance") } else { allow() } @@ -525,13 +543,6 @@ fn validate_delete_provider_profile() -> InterceptorResult { deny("provider profile deletes are blocked by provider governance") } -fn provider_name(operation: &Value) -> &str { - operation - .pointer("/provider/metadata/name") - .and_then(Value::as_str) - .unwrap_or_default() -} - fn provider_type(operation: &Value) -> &str { operation .pointer("/provider/type") @@ -661,6 +672,34 @@ fn loaded_profile_ids(profiles: &[LoadedProviderProfile]) -> Vec { .collect() } +fn profile_state_from_loaded(profiles: Vec) -> ProviderProfileState { + let ids = loaded_profile_ids(&profiles); + let profiles = profiles + .into_iter() + .map(|loaded| loaded.profile) + .collect::>(); + ProviderProfileState { + revision: provider_profile_revision(&profiles), + ids, + profiles, + } +} + +fn provider_profile_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-governance-provider-profiles-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +fn is_managed_profile_id(managed_profile_ids: &[String], id: &str) -> bool { + managed_profile_ids.iter().any(|managed| managed == id) +} + fn format_id_list(ids: &[String]) -> String { ids.join(", ") } @@ -1078,18 +1117,16 @@ async fn main() -> Result<(), Box> { include_str!("../policy.yaml").to_string() }; let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); - let profiles = load_provider_profiles(&profiles_path)?; - let service = GovernanceInterceptorService::from_yaml(&policy_yaml, profiles)?; + let service = + GovernanceInterceptorService::from_policy_and_profiles_path(&policy_yaml, profiles_path)?; if let Some(endpoint) = gateway_endpoint { println!( - "--gateway-endpoint is ignored; provider profiles are vended through the interceptor manifest ({endpoint})" + "--gateway-endpoint is ignored; provider profiles are vended through the interceptor snapshot API ({endpoint})" ); } - println!( - "loaded provider profiles: {}", - service.managed_profile_ids.join(", ") - ); + let profile_state = service.current_profile_state(); + println!("loaded provider profiles: {}", profile_state.ids.join(", ")); println!("governance interceptor listening on {listen}"); Server::builder() @@ -1106,10 +1143,11 @@ fn default_profiles_path() -> PathBuf { #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn service() -> GovernanceInterceptorService { let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); - GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml"), profiles).unwrap() + GovernanceInterceptorService::from_profiles(profiles).unwrap() } fn evaluation( @@ -1129,6 +1167,10 @@ mod tests { } } + fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec { + service.current_profile_state().ids + } + fn governed_create_operation( service: &GovernanceInterceptorService, policy: Value, @@ -1137,7 +1179,7 @@ mod tests { let mut operation = json!({ "spec": { "policy": policy, - "providers": &service.managed_profile_ids, + "providers": managed_profile_ids(service), }, "annotations": {}, }); @@ -1193,19 +1235,29 @@ mod tests { .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!(ids.contains(&"govern-import-provider-profiles")); assert!(ids.contains(&"govern-update-provider-profiles")); assert!(ids.contains(&"govern-delete-provider-profile")); + assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-create-sandbox")); + assert!(!ids.contains(&"govern-attach-provider")); + assert!(!ids.contains(&"govern-detach-provider")); + assert!(!ids.contains(&"govern-update-provider")); + assert!(!ids.contains(&"govern-delete-provider")); assert_eq!(manifest.failure_policy, "fail_closed"); - let catalog = manifest - .provider_profile_catalog - .expect("manifest includes provider catalog"); - assert_eq!(catalog.source_id, PROFILE_CATALOG_SOURCE_ID); - assert_eq!(catalog.mode, ProviderProfileCatalogMode::Authoritative as i32); - let profile_ids = catalog + assert!(manifest.provider_profiles); + } + + #[tokio::test] + async fn snapshot_provider_profiles_returns_current_profiles() { + let service = service(); + let snapshot = service + .snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})) + .await + .unwrap() + .into_inner(); + assert!(!snapshot.revision.is_empty()); + let profile_ids = snapshot .profiles .iter() .map(|profile| profile.id.as_str()) @@ -1255,6 +1307,7 @@ binaries: [] json!({"name": "demo", "spec": {}, "labels": {"team": "platform"}}), )) .unwrap(); + assert!(result.allowed); let paths: Vec<_> = result .patches @@ -1304,7 +1357,7 @@ binaries: [] json!({ "spec": { "policy": service.policy, - "providers": service.managed_profile_ids, + "providers": managed_profile_ids(&service), }, }), )) @@ -1383,13 +1436,13 @@ binaries: [] } #[test] - fn provider_creation_is_limited_to_governed_names() { + fn provider_creation_is_limited_to_vended_profiles() { let service = service(); let github = service .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "github"}, "type": "github"}}), + json!({"provider": {"metadata": {"name": "work-github"}, "type": "github"}}), )) .unwrap(); assert!(github.allowed); @@ -1398,21 +1451,11 @@ binaries: [] .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "slack"}, "type": "slack"}}), + json!({"provider": {"metadata": {"name": "team-chat"}, "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", @@ -1421,6 +1464,11 @@ binaries: [] )) .unwrap(); assert!(!teams.allowed); + assert!( + teams + .reason + .contains("providers may only use vended provider profiles") + ); } #[test] @@ -1505,18 +1553,25 @@ binaries: [] } #[test] - fn provider_attach_and_detach_are_denied() { + fn provider_update_and_delete_are_not_governed() { 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); - } + let update = service + .evaluate_inner(&evaluation( + "UpdateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}}}), + )) + .unwrap(); + assert!(update.allowed); + + let delete = service + .evaluate_inner(&evaluation( + "DeleteProvider", + GatewayInterceptorPhase::Validate, + json!({"name": "github"}), + )) + .unwrap(); + assert!(delete.allowed); } #[test] @@ -1525,6 +1580,7 @@ binaries: [] for operation in [ json!({"name": "demo", "policy": {"version": 1}}), json!({"name": "demo", "mergeOperations": [{"op": "add"}]}), + json!({"name": "demo", "merge_operations": [{"op": "add"}]}), ] { let result = service .evaluate_inner(&evaluation( @@ -1535,27 +1591,37 @@ binaries: [] .unwrap(); assert!(!result.allowed); } - } - #[test] - fn governed_provider_update_and_delete_are_denied() { - let service = service(); - let update = service + let settings_update = service .evaluate_inner(&evaluation( - "UpdateProvider", + "UpdateConfig", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "slack"}}}), + json!({"global": true, "settingKey": "providers_v2_enabled"}), )) .unwrap(); - assert!(!update.allowed); + assert!(settings_update.allowed); - let delete = service + let global_policy_update = service .evaluate_inner(&evaluation( - "DeleteProvider", + "UpdateConfig", GatewayInterceptorPhase::Validate, - json!({"name": "github"}), + json!({"global": true, "policy": {"version": 1}}), )) .unwrap(); - assert!(!delete.allowed); + assert!(global_policy_update.allowed); + + let mut sandbox_policy_sync = evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": {"version": 1}}), + ); + sandbox_policy_sync + .principal + .insert("kind".to_string(), "sandbox".to_string()); + sandbox_policy_sync + .principal + .insert("sandbox_id".to_string(), "demo-id".to_string()); + let sandbox_policy_sync = service.evaluate_inner(&sandbox_policy_sync).unwrap(); + assert!(sandbox_policy_sync.allowed); } } diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index ec0ba7e435..357f559500 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -15,6 +15,11 @@ service GatewayInterceptor { // Describe returns the interceptor manifest and declared bindings. rpc Describe(DescribeRequest) returns (InterceptorManifest); + // SnapshotProviderProfiles returns the interceptor's current provider + // profile snapshot when the manifest advertises provider_profiles = true. + rpc SnapshotProviderProfiles(ProviderProfileSnapshotRequest) + returns (ProviderProfileSnapshot); + // Evaluate returns an allow, deny, or mutation decision for one operation // phase. rpc Evaluate(InterceptorEvaluation) returns (InterceptorResult); @@ -22,6 +27,8 @@ service GatewayInterceptor { message DescribeRequest {} +message ProviderProfileSnapshotRequest {} + enum GatewayInterceptorPhase { GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; @@ -70,23 +77,15 @@ message InterceptorManifest { // Optional default failure policy for bindings without their own policy. // Supported values are "fail_closed" and "fail_open". string failure_policy = 3; - // Optional profile catalog vended by this interceptor. - ProviderProfileCatalog provider_profile_catalog = 4; -} - -enum ProviderProfileCatalogMode { - PROVIDER_PROFILE_CATALOG_MODE_UNSPECIFIED = 0; - PROVIDER_PROFILE_CATALOG_MODE_APPEND = 1; - PROVIDER_PROFILE_CATALOG_MODE_AUTHORITATIVE = 2; + // True when this interceptor implements SnapshotProviderProfiles. + bool provider_profiles = 4; } -message ProviderProfileCatalog { - // Stable source id, for example "interceptor/provider-governance". - string source_id = 1; - // Merge behavior for this source. - ProviderProfileCatalogMode mode = 2; +message ProviderProfileSnapshot { + // Opaque source revision used for cache freshness and sandbox reload checks. + string revision = 1; // Complete profile snapshot vended by this source. - repeated openshell.v1.ProviderProfile profiles = 3; + repeated openshell.v1.ProviderProfile profiles = 2; } message InterceptorBinding { From 81f0e6cd2d6041ae6d37bf266cf1073cc968b0a3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 23:10:46 -0700 Subject: [PATCH 12/27] feat(governance-interceptor): sign provider profiles Signed-off-by: Drew Newberry --- .../openshell-gateway-interceptors/src/lib.rs | 31 +- crates/openshell-providers/src/discovery.rs | 1 + crates/openshell-providers/src/profiles.rs | 41 + crates/openshell-server/src/grpc/policy.rs | 7 + crates/openshell-server/src/grpc/provider.rs | 4 + .../src/provider_profile_sources.rs | 12 +- docs/sandboxes/providers-v2.mdx | 3 + examples/governance-interceptor/Cargo.lock | 86 +- examples/governance-interceptor/Cargo.toml | 2 +- examples/governance-interceptor/README.md | 30 +- examples/governance-interceptor/policy.yaml | 12 +- examples/governance-interceptor/smoke.sh | 188 ++- examples/governance-interceptor/src/main.rs | 1339 +++++++---------- .../governance-interceptor/src/policy_hash.rs | 97 ++ examples/governance-interceptor/src/tests.rs | 658 ++++++++ proto/openshell.proto | 2 + 16 files changed, 1681 insertions(+), 832 deletions(-) create mode 100644 examples/governance-interceptor/src/policy_hash.rs create mode 100644 examples/governance-interceptor/src/tests.rs diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index e689f58820..8bc0b30b4f 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -30,7 +30,7 @@ use openshell_core::proto::gateway_interceptor::v1::{ InterceptorResult, InterceptorSelector, JsonPatch, ProviderProfileSnapshotRequest, gateway_interceptor_client::GatewayInterceptorClient, }; -use prost::Message as _; +use prost::Message; use prost_types::{ DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, Struct, @@ -66,6 +66,35 @@ pub enum InterceptorError { pub type Result = std::result::Result; +#[derive(Debug, Clone)] +pub struct ProtoJsonCodec { + descriptors: Arc, +} + +impl ProtoJsonCodec { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + Ok(Self { + descriptors: Arc::new(ProtoDescriptors::from_descriptor_set(bytes)?), + }) + } + + pub fn openshell() -> Result { + Self::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + } + + pub fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: Message, + { + self.descriptors + .decode_message_to_json(type_name, &message.encode_to_vec()) + } + + pub fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + self.descriptors.encode_json_to_message(type_name, value) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Phase { ModifyOperation, diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index ebe75e4349..7e212590d5 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -84,6 +84,7 @@ mod tests { ProviderTypeProfile { id: "custom".to_string(), resource_version: 0, + annotations: std::collections::HashMap::new(), display_name: "Custom".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other, diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 3820c346cb..62809138cd 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -297,6 +297,8 @@ pub struct ProviderTypeProfile { pub id: String, #[serde(default, skip_serializing_if = "is_u64_zero")] pub resource_version: u64, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub annotations: HashMap, pub display_name: String, #[serde(default)] pub description: String, @@ -328,6 +330,7 @@ impl ProviderTypeProfile { Self { id: profile.id.clone(), resource_version: profile.resource_version, + annotations: profile.annotations.clone(), display_name: profile.display_name.clone(), description: profile.description.clone(), category: ProviderProfileCategory::try_from(profile.category) @@ -418,6 +421,7 @@ impl ProviderTypeProfile { ProviderProfile { id: self.id.clone(), resource_version: self.resource_version, + annotations: self.annotations.clone(), display_name: self.display_name.clone(), description: self.description.clone(), category: self.category as i32, @@ -1710,6 +1714,8 @@ pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { #[cfg(test)] mod tests { + use std::collections::HashMap; + use openshell_core::proto::ProviderProfileCategory; use super::{ @@ -2442,6 +2448,38 @@ endpoints: assert_eq!(parsed.binaries[0].path, "/usr/bin/gh"); } + #[test] + fn profile_annotations_round_trip_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: signed +annotations: + openshell.nvidia.com/profile-hash: sha256:abc123 + openshell.nvidia.com/profile-signature: signed-token +display_name: Signed +description: Signed provider profile +credentials: [] +endpoints: [] +binaries: [] +", + ) + .expect("profile should parse"); + + let proto = profile.to_proto(); + assert_eq!( + proto + .annotations + .get("openshell.nvidia.com/profile-signature") + .map(String::as_str), + Some("signed-token") + ); + + let exported = profile_to_yaml(&ProviderTypeProfile::from_proto(&proto)) + .expect("profile should serialize"); + let reparsed = parse_profile_yaml(&exported).expect("exported profile should parse"); + assert_eq!(reparsed.annotations, profile.annotations); + } + #[test] fn profile_yaml_round_trip_preserves_full_network_policy_fields() { let profile = parse_profile_yaml( @@ -2593,6 +2631,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: " alex-api ".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Space".to_string(), description: String::new(), category: ProviderProfileCategory::Other, @@ -2608,6 +2647,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: "alex_api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Underscore".to_string(), description: String::new(), category: ProviderProfileCategory::Other, @@ -2623,6 +2663,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: "Alex-API".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Case".to_string(), description: String::new(), category: ProviderProfileCategory::Other, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index b606d339d6..5b04ed4972 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4641,6 +4641,7 @@ mod tests { profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Generic Override".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4687,6 +4688,7 @@ mod tests { profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4753,6 +4755,7 @@ mod tests { profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -5020,6 +5023,7 @@ mod tests { profile: Some(ProviderProfile { id: "custom-policy".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Policy".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5305,6 +5309,7 @@ mod tests { profile: Some(ProviderProfile { id: "custom-token".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Token".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5525,6 +5530,7 @@ mod tests { profile: Some(ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -7641,6 +7647,7 @@ mod tests { profile: Some(ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 24673efae0..b241375924 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2536,6 +2536,7 @@ mod tests { let profile = ProviderProfile { id: "keycloak-sso".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Keycloak SSO".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -3089,6 +3090,7 @@ mod tests { ProviderProfile { id: id.to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: format!("{id} Profile"), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -3528,6 +3530,7 @@ mod tests { profile: Some(ProviderProfile { id: "advanced-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Advanced API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -4661,6 +4664,7 @@ mod tests { profile: Some(ProviderProfile { id: "delegated-refresh-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Delegated Refresh API".to_string(), description: String::new(), category: ProviderProfileCategory::Messaging as i32, diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 6b0f09cf81..ade1143135 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -369,9 +369,7 @@ fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { format!("sha256:{:x}", hasher.finalize()) } -pub(crate) async fn user_provider_profiles( - store: &Store, -) -> Result, Status> { +pub async fn user_provider_profiles(store: &Store) -> Result, Status> { let profiles: Vec = store .list_messages(10_000, 0) .await @@ -379,7 +377,7 @@ pub(crate) async fn user_provider_profiles( Ok(profiles) } -pub(crate) fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { +pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { use crate::persistence::current_time_ms; let now_ms = current_time_ms(); let profile = profile_storage_payload(profile); @@ -396,12 +394,12 @@ pub(crate) fn stored_provider_profile(profile: ProviderProfile) -> StoredProvide } } -pub(crate) fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { +pub fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { profile.resource_version = 0; profile } -pub(crate) fn profile_response_payload( +pub fn profile_response_payload( mut profile: ProviderProfile, resource_version: u64, ) -> ProviderProfile { @@ -409,7 +407,7 @@ pub(crate) fn profile_response_payload( profile } -pub(crate) fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { +pub fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { stored .metadata .as_ref() diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 035644a2f9..e934ff301e 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -165,11 +165,14 @@ The `category` field controls how `openshell provider list-profiles` groups prof ### Profile Schema Provider profile YAML and JSON use this shape. Treat this as a field map, not a profile to import verbatim. The endpoint and rule fields mirror the network policy schema used under `network_policies`. Refer to [Policy Schema Reference](/reference/policy-schema) for field semantics. +Use `annotations` only for non-secret metadata such as source, signature, or governance markers. OpenShell preserves annotations through profile import, export, and interceptor-managed profile snapshots. ```yaml wordWrap showLineNumbers={false} id: custom-api # Present on exported custom profiles; preserve it when updating. resource_version: 1 +annotations: + example.com/source: platform display_name: Custom API description: Custom API access for sandbox agents category: data diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index a54a65891c..ab85d179e1 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -679,6 +679,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9ad60d674508f3ca8f380a928cfe7b096bc729c4e2dbfe3852bc45da3ab30b" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -749,6 +760,16 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + [[package]] name = "miette" version = "7.6.0" @@ -872,7 +893,7 @@ dependencies = [ "protobuf-src", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", "tonic-prost", @@ -882,12 +903,32 @@ dependencies = [ ] [[package]] -name = "openshell-governance-interceptor-example" +name = "openshell-gateway-interceptors" version = "0.0.0" dependencies = [ "base64", + "hyper-util", + "json-patch", + "metrics", + "openshell-core", + "prost", + "prost-types", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +dependencies = [ "jsonwebtoken", "openshell-core", + "openshell-gateway-interceptors", "openshell-policy", "openshell-providers", "prost", @@ -921,7 +962,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", - "thiserror", + "thiserror 2.0.18", "url", ] @@ -1013,6 +1054,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1144,6 +1191,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rapidhash" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" +dependencies = [ + "rustversion", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1417,7 +1473,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -1537,13 +1593,33 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index 287f442d1f..d38b412c88 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -11,9 +11,9 @@ rust-version = "1.88" license = "Apache-2.0" [dependencies] -base64 = "0.22" jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-gateway-interceptors = { path = "../../crates/openshell-gateway-interceptors" } openshell-policy = { path = "../../crates/openshell-policy" } openshell-providers = { path = "../../crates/openshell-providers" } prost = "0.14" diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index f06c56eef1..36b3f84ec6 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -9,8 +9,10 @@ authoritative profile source. - `provider list-profiles` shows only the profiles vended by this interceptor - providers can only be created with a `type` that matches one of those vended profile IDs -- every new sandbox receives `policy.yaml` and the vended provider set during - `CreateSandbox` +- every vended provider profile gets governance annotations for its hash, + signature, and signing key ID +- every new sandbox receives `policy.yaml` during `CreateSandbox` +- requested sandbox providers must match one of the vended profile IDs - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation that is used to verify the policy - sandbox creation evaluations add a `correlation_id` log annotation for gateway @@ -25,7 +27,8 @@ Run the interceptor: cargo run -- \ --listen 127.0.0.1:18081 \ --policy policy.yaml \ - --profiles profiles + --profiles profiles \ + --gateway-endpoint http://127.0.0.1:8080 ``` At startup the example parses `policy.yaml`, converts it to the protobuf JSON @@ -38,6 +41,15 @@ start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. +The interceptor polls the policy file every second by default. When `policy.yaml` +changes and parses successfully, the interceptor re-signs it immediately. New +sandboxes receive the updated signed policy through `CreateSandbox`. If +`--gateway-endpoint` is set, the example also lists running sandboxes and calls +`UpdateConfig` for ready or provisioning sandboxes so dynamic policy changes +propagate through the normal sandbox config polling path. Static baseline +changes that the gateway rejects for existing sandboxes are logged and still +apply to newly created sandboxes. + 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 @@ -48,10 +60,14 @@ The interceptor advertises `provider_profiles = true` in its manifest and vends the current profile set through `SnapshotProviderProfiles`. While the interceptor is attached, the gateway uses that snapshot as the profile source: `provider list-profiles` shows only `github` and `slack`, and built-in/user -sources are omitted. Valid edits to files under `profiles/` change the snapshot -revision, so running sandboxes that use the edited provider profile reload their -effective provider-derived policy through the normal gateway config polling -path. Invalid edits keep the last valid snapshot active. +sources are omitted. The example signs each profile's canonical protobuf +payload and exposes the JWT under +`annotations["openshell.nvidia.com/profile-signature"]`; the signed hash and key +ID are exposed beside it. Valid edits to files under `profiles/` change the +profile signature and snapshot revision, so running sandboxes that use the +edited provider profile reload their effective provider-derived policy through +the normal gateway config polling path. Invalid edits keep the last valid +snapshot active. Gateway TOML snippet: diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml index 407baef228..021e635db2 100644 --- a/examples/governance-interceptor/policy.yaml +++ b/examples/governance-interceptor/policy.yaml @@ -15,4 +15,14 @@ process: run_as_user: sandbox run_as_group: sandbox -network_policies: {} +network_policies: + my_api: + name: my-api + endpoints: + - host: api-1.example.com + port: 443 + protocol: rest + enforcement: enforce + access: full + binaries: + - path: /usr/bin/curl diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index c939c0306c..e5f070d616 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -43,6 +43,8 @@ TMPDIR="$(mktemp -d)" LOG_DIR="$TMPDIR/logs" JWT_DIR="$TMPDIR/jwt" GATEWAY_CONFIG="$TMPDIR/gateway.toml" +POLICY_FILE="$TMPDIR/policy.yaml" +PROFILE_DIR="$TMPDIR/profiles" SETUP_LOG="$LOG_DIR/setup.log" GATEWAY_LOG="$LOG_DIR/gateway.log" INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" @@ -53,7 +55,8 @@ else fi SANDBOX_NAME="$RUN_ID-sandbox" -mkdir -p "$LOG_DIR" +mkdir -p "$LOG_DIR" "$PROFILE_DIR" +cp "$EXAMPLE_DIR"/profiles/*.yaml "$PROFILE_DIR"/ cleanup() { local status=$? @@ -245,6 +248,46 @@ expect_log_contains() { fi } +wait_for_output_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + + log_command "$label" "$@" + for _ in {1..60}; do + if "$@" >"$output_file" 2>>"$SETUP_LOG" && grep -Fq -- "$needle" "$output_file"; then + pass "$label" + return + fi + sleep 1 + done + + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" +} + +policy_hash_for_sandbox() { + local sandbox_name="$1" + + "${CLI[@]}" policy get "$sandbox_name" --full -o json \ + | awk -F'"' '/"hash":/ { print $4; exit }' +} + +policy_signature_for_sandbox() { + local sandbox_name="$1" + + "${CLI[@]}" sandbox get "$sandbox_name" \ + | awk -F': ' '/openshell.nvidia.com\/policy-signature:/ { print $2; exit }' +} + +profile_signature_for_profile() { + local profile_id="$1" + + "${CLI[@]}" provider profile export "$profile_id" -o json \ + | awk -F'"' '/"openshell.nvidia.com\/profile-signature":/ { print $4; exit }' +} + wait_for_profile() { local profile_id="$1" local label="loading $profile_id provider profile" @@ -307,8 +350,10 @@ start_interceptor() { printf 'INFO starting governance interceptor\n' "$EXAMPLE_DIR/target/debug/governance-interceptor" \ --listen "$INTERCEPTOR_ADDR" \ - --policy "$EXAMPLE_DIR/policy.yaml" \ - --profiles "$EXAMPLE_DIR/profiles" >"$INTERCEPTOR_LOG" 2>&1 & + --policy "$POLICY_FILE" \ + --profiles "$PROFILE_DIR" \ + --gateway-endpoint "$GATEWAY_ENDPOINT" \ + --policy-watch-interval-ms 250 >"$INTERCEPTOR_LOG" 2>&1 & INTERCEPTOR_PID=$! } @@ -363,6 +408,8 @@ run_suite() { expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles expect_output_not_contains "hides codex profile" "codex" "${CLI[@]}" provider list-profiles expect_output_not_contains "hides google cloud profile" "google-cloud" "${CLI[@]}" provider list-profiles + expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" provider profile export github -o json + expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" provider profile export github -o json cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' id: custom-slack @@ -382,13 +429,140 @@ EOF expect_failure "denies disallowed provider create" "${CLI[@]}" provider create --name bitbucket --type bitbucket --credential BITBUCKET_TOKEN=dummy - run_step "creates sandbox with explicit providers" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --provider github --provider slack --no-auto-providers --keep --no-tty -- /bin/sh -lc true + run_step "creates sandbox with selected github provider" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --provider github --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_not_contains "sandbox does not auto-add 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_output_not_contains "effective policy omits unselected slack layer" "_provider_slack" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + + local initial_policy_signature + initial_policy_signature="$(policy_signature_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$initial_policy_signature" ]]; then + fail "reads initial governance policy signature" + fi + pass "reads initial governance policy signature" + + local initial_github_profile_signature + initial_github_profile_signature="$(profile_signature_for_profile github)" + if [[ -z "$initial_github_profile_signature" ]]; then + fail "reads initial governance profile signature" + fi + pass "reads initial governance profile signature" + + cat >"$POLICY_FILE" <<'EOF' +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: + example_api: + name: example-api + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only +EOF + wait_for_output_contains "gateway sees policy.yaml reload" "example_api" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + local policy_reload_hash + policy_reload_hash="$(policy_hash_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$policy_reload_hash" ]]; then + fail "reads reloaded policy.yaml hash" + fi + wait_for_output_contains "running sandbox logs policy.yaml reload" "$policy_reload_hash" "${CLI[@]}" logs "$SANDBOX_NAME" --source sandbox --since 90s + + local reloaded_policy_signature="" + { + printf '\n== policy.yaml reload updates sandbox policy signature ==\n' + printf '+ wait for sandbox annotation %q to change\n' "openshell.nvidia.com/policy-signature" + } >>"$SETUP_LOG" + for _ in {1..60}; do + reloaded_policy_signature="$(policy_signature_for_sandbox "$SANDBOX_NAME")" + if [[ -n "$reloaded_policy_signature" && "$reloaded_policy_signature" != "$initial_policy_signature" ]]; then + break + fi + sleep 1 + done + if [[ -z "$reloaded_policy_signature" || "$reloaded_policy_signature" == "$initial_policy_signature" ]]; then + fail "policy.yaml reload updates sandbox policy signature" + fi + pass "policy.yaml reload updates sandbox policy signature" + + cat >"$PROFILE_DIR/github.yaml" <<'EOF' +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 + - host: profile-reload.example + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] +EOF + wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" provider profile export github -o yaml + wait_for_output_contains "effective policy has reloaded github profile" "profile-reload.example" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + local reloaded_github_profile_signature="" + { + printf '\n== github profile reload updates profile signature ==\n' + printf '+ wait for provider profile annotation %q to change\n' "openshell.nvidia.com/profile-signature" + } >>"$SETUP_LOG" + for _ in {1..60}; do + reloaded_github_profile_signature="$(profile_signature_for_profile github)" + if [[ -n "$reloaded_github_profile_signature" && "$reloaded_github_profile_signature" != "$initial_github_profile_signature" ]]; then + break + fi + sleep 1 + done + if [[ -z "$reloaded_github_profile_signature" || "$reloaded_github_profile_signature" == "$initial_github_profile_signature" ]]; then + fail "github profile reload updates profile signature" + fi + pass "github profile reload updates profile signature" + local profile_reload_hash + profile_reload_hash="$(policy_hash_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$profile_reload_hash" ]]; then + fail "reads reloaded profile policy hash" + fi + wait_for_output_contains "running sandbox logs github profile reload" "$profile_reload_hash" "${CLI[@]}" logs "$SANDBOX_NAME" --source sandbox --since 90s + 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" @@ -402,6 +576,7 @@ READY governance interceptor gateway Gateway endpoint: $GATEWAY_ENDPOINT Gateway health check: http://$HEALTH_ADDR/healthz Gateway config: $GATEWAY_CONFIG +Profile dir: $PROFILE_DIR Setup log: $SETUP_LOG Gateway log: $GATEWAY_LOG Interceptor log: $INTERCEPTOR_LOG @@ -434,6 +609,7 @@ run_setup_step "building governance interceptor" cargo build --quiet --manifest- run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle +cp "$EXAMPLE_DIR/policy.yaml" "$POLICY_FILE" write_gateway_config start_interceptor start_gateway diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 28c004fa25..e41500b367 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -1,13 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +mod policy_hash; + use std::collections::HashMap; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, }; @@ -18,11 +19,13 @@ use openshell_core::proto::gateway_interceptor::v1::{ gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; use openshell_core::proto::{ - GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, - ProviderProfile, SandboxPolicy, + ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, + UpdateConfigRequest, open_shell_client::OpenShellClient, }; +use openshell_gateway_interceptors::ProtoJsonCodec; use openshell_policy::parse_sandbox_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; +use policy_hash::deterministic_policy_hash; use prost::Message as _; use prost_types::ListValue; use prost_types::{Struct, Value as ProtoValue, value::Kind}; @@ -30,15 +33,28 @@ 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"; +const POLICY_HASH_ANNOTATION: &str = "openshell.nvidia.com/policy-hash"; +const POLICY_SIGNATURE_KID_ANNOTATION: &str = "openshell.nvidia.com/policy-signature-kid"; +const POLICY_RELOAD_CORRELATION_ANNOTATION: &str = + "openshell.nvidia.com/policy-reload-correlation-id"; +const PROFILE_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/profile-signature"; +const PROFILE_HASH_ANNOTATION: &str = "openshell.nvidia.com/profile-hash"; +const PROFILE_SIGNATURE_KID_ANNOTATION: &str = "openshell.nvidia.com/profile-signature-kid"; const POLICY_JWT_ISSUER: &str = "openshell-governance-interceptor"; const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; const POLICY_JWT_SUBJECT: &str = "policy.yaml"; +const PROFILE_JWT_AUDIENCE: &str = "openshell-governance-profile"; +const PROFILE_JWT_SUBJECT_PREFIX: &str = "provider-profile:"; const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; +const RELOAD_CORRELATION_PREFIX: &str = "governance:reload-policy"; const SERVICE: &str = "openshell.v1.OpenShell"; +const SANDBOX_POLICY_TYPE: &str = "openshell.sandbox.v1.SandboxPolicy"; +const DEFAULT_POLICY_WATCH_INTERVAL_MS: u64 = 1_000; #[derive(Clone)] struct PolicySigner { @@ -65,6 +81,17 @@ struct PolicySignatureClaims { policy_sha256: String, } +#[derive(Debug, Serialize, Deserialize)] +struct ProfileSignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + profile_id: String, + profile_sha256: String, +} + impl PolicySigner { fn generate() -> Result { let keypair = KeyPair::generate_for(&PKCS_ED25519) @@ -102,6 +129,22 @@ impl PolicySigner { .map_err(|err| format!("failed to sign policy JWT: {err}")) } + fn sign_profile(&self, profile_id: &str, profile_hash: &str) -> Result { + let claims = ProfileSignatureClaims { + sub: format!("{PROFILE_JWT_SUBJECT_PREFIX}{profile_id}"), + iss: POLICY_JWT_ISSUER.to_string(), + aud: PROFILE_JWT_AUDIENCE.to_string(), + iat: 0, + exp: 0, + profile_id: profile_id.to_string(), + profile_sha256: profile_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 provider profile 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}"))?; @@ -129,18 +172,62 @@ impl PolicySigner { } Ok(()) } + + #[cfg(test)] + fn verify_profile_signature( + &self, + token: &str, + profile_id: &str, + profile_hash: &str, + ) -> Result<(), String> { + let header = decode_header(token) + .map_err(|err| format!("failed to decode provider profile JWT header: {err}"))?; + if header.kid.as_deref() != Some(self.kid.as_str()) { + return Err("unexpected provider profile signing key id".to_string()); + } + if header.alg != Algorithm::EdDSA { + return Err("unexpected provider profile 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(&[PROFILE_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 provider profile JWT: {err}"))?; + if data.claims.sub != format!("{PROFILE_JWT_SUBJECT_PREFIX}{profile_id}") { + return Err("unexpected provider profile JWT subject".to_string()); + } + if data.claims.profile_id != profile_id { + return Err("unexpected provider profile id".to_string()); + } + if data.claims.profile_sha256 != profile_hash { + return Err("signed provider profile hash does not match profile".to_string()); + } + Ok(()) + } } #[derive(Clone, Debug)] struct GovernanceInterceptorService { - policy: Value, - policy_hash: String, - policy_signature: String, policy_signer: PolicySigner, + policy_state: Arc>, profiles_path: Option, profile_state: Arc>, } +#[derive(Clone, Debug)] +struct PolicyState { + policy: Value, + policy_proto: SandboxPolicy, + policy_hash: String, + policy_signature: String, + policy_signature_kid: String, +} + #[derive(Clone, Debug)] struct ProviderProfileState { ids: Vec, @@ -172,19 +259,12 @@ impl GovernanceInterceptorService { if profiles.is_empty() { return Err("at least one provider profile must be loaded".to_string()); } - let profile_state = profile_state_from_loaded(profiles); - 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_hash = policy_hash(&policy)?; let policy_signer = PolicySigner::generate()?; - let policy_signature = policy_signer.sign_policy(&policy_hash)?; + let profile_state = profile_state_from_loaded(profiles, &policy_signer)?; + let policy_state = load_policy_state(policy_yaml, &policy_signer)?; Ok(Self { - policy, - policy_hash, - policy_signature, policy_signer, + policy_state: Arc::new(RwLock::new(policy_state)), profiles_path, profile_state: Arc::new(RwLock::new(profile_state)), }) @@ -238,6 +318,7 @@ impl GovernanceInterceptorService { evaluation: &InterceptorEvaluation, ) -> Result { let profile_state = self.current_profile_state(); + let policy_state = self.current_policy_state(); let phase = GatewayInterceptorPhase::try_from(evaluation.phase) .map_err(|_| Status::invalid_argument("unknown interceptor phase"))?; let operation = evaluation @@ -248,17 +329,23 @@ impl GovernanceInterceptorService { match (evaluation.method.as_str(), phase) { ("CreateSandbox", GatewayInterceptorPhase::ModifyOperation) => { - self.patch_create_sandbox(&operation, &profile_state.ids) - } - ("CreateSandbox", GatewayInterceptorPhase::Validate) => { - Ok(self.validate_create_sandbox(&operation, &profile_state.ids)) + Self::patch_create_sandbox(&operation, &policy_state) } + ("CreateSandbox", GatewayInterceptorPhase::Validate) => Ok(validate_create_sandbox( + &operation, + &profile_state.ids, + &policy_state, + &self.policy_signer, + )), ("CreateProvider", GatewayInterceptorPhase::Validate) => { Ok(self.validate_create_provider(&operation, &profile_state.ids)) } - ("UpdateConfig", GatewayInterceptorPhase::Validate) => { - Ok(validate_update_config(&operation, &evaluation.principal)) - } + ("UpdateConfig", GatewayInterceptorPhase::Validate) => Ok(validate_update_config( + &operation, + &evaluation.principal, + &policy_state, + &self.policy_signer, + )), ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) } @@ -273,30 +360,27 @@ impl GovernanceInterceptorService { } fn patch_create_sandbox( - &self, operation: &Value, - managed_profile_ids: &[String], + policy_state: &PolicyState, ) -> 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!(managed_profile_ids), + "/spec/policy", + policy_state.policy.clone(), )?); } else { patches.push(json_patch( "add", "/spec", json!({ - "policy": self.policy, - "providers": managed_profile_ids, + "policy": policy_state.policy.clone(), }), )?); } - add_policy_signature_patches(operation, &mut patches, &self.policy_signature)?; + add_policy_signature_patches(operation, &mut patches, &policy_state.policy_signature)?; let mut result = allow(); result.patches = patches; @@ -306,58 +390,20 @@ impl GovernanceInterceptorService { ); result .log_annotations - .insert("policy_hash".to_string(), self.policy_hash.clone()); + .insert("policy_hash".to_string(), policy_state.policy_hash.clone()); result.log_annotations.insert( "policy_signature_kid".to_string(), - self.policy_signer.kid().to_string(), + policy_state.policy_signature_kid.clone(), ); Ok(result) } - fn validate_create_sandbox( - &self, - operation: &Value, - managed_profile_ids: &[String], - ) -> InterceptorResult { - let Some(policy) = operation.pointer("/spec/policy") else { - return deny("sandbox policy must match the provider 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 provider governance baseline"); - } - if !providers_are_managed(operation.pointer("/spec/providers"), managed_profile_ids) { - return deny(&format!( - "sandbox providers must be exactly {}", - format_id_list(managed_profile_ids) - )); - } - allow() - } - fn current_profile_state(&self) -> ProviderProfileState { if let Some(path) = &self.profiles_path { - match load_provider_profiles(path) { - Ok(profiles) => { - let state = profile_state_from_loaded(profiles); + match load_provider_profiles(path) + .and_then(|profiles| profile_state_from_loaded(profiles, &self.policy_signer)) + { + Ok(state) => { if let Ok(mut guard) = self.profile_state.write() { *guard = state.clone(); } @@ -376,6 +422,26 @@ impl GovernanceInterceptorService { .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) } + fn current_policy_state(&self) -> PolicyState { + self.policy_state + .read() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + fn reload_policy_from_yaml(&self, policy_yaml: &str) -> Result, String> { + let next = load_policy_state(policy_yaml, &self.policy_signer)?; + let mut guard = self + .policy_state + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.policy_hash == next.policy_hash { + return Ok(None); + } + *guard = next.clone(); + Ok(Some(next)) + } + fn validate_create_provider( &self, operation: &Value, @@ -513,9 +579,62 @@ fn deny(reason: &str) -> InterceptorResult { } } +fn validate_create_sandbox( + operation: &Value, + managed_profile_ids: &[String], + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> InterceptorResult { + let Some(policy) = operation.pointer("/spec/policy") else { + return deny("sandbox policy must match the provider governance baseline"); + }; + 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"); + }; + let signature_validation = + validate_signed_policy_payload(policy, signature, policy_state, policy_signer); + if let Err(reason) = signature_validation { + return deny(&reason); + } + if !providers_are_managed(operation.pointer("/spec/providers"), managed_profile_ids) { + return deny(&format!( + "sandbox providers may only use vended provider profiles: {}", + format_id_list(managed_profile_ids) + )); + } + allow() +} + +fn validate_signed_policy_payload( + policy: &Value, + signature: &str, + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> Result<(), String> { + let sandbox_policy = sandbox_policy_from_interceptor_json(policy)?; + let sandbox_policy_hash = deterministic_policy_hash(&sandbox_policy); + policy_signer + .verify_policy_signature(signature, &sandbox_policy_hash) + .map_err(|err| format!("sandbox policy signature is invalid: {err}"))?; + if sandbox_policy_hash != policy_state.policy_hash + || sandbox_policy != policy_state.policy_proto + { + return Err("sandbox policy must match the provider governance baseline".to_string()); + } + Ok(()) +} + fn validate_update_config( operation: &Value, principal: &HashMap, + policy_state: &PolicyState, + policy_signer: &PolicySigner, ) -> InterceptorResult { if principal.get("kind").is_some_and(|kind| kind == "sandbox") { return allow(); @@ -532,17 +651,79 @@ fn validate_update_config( .or_else(|| operation.get("merge_operations")) .and_then(Value::as_array) .is_some_and(|operations| !operations.is_empty()); - if !is_global && (has_policy || has_merge_operations) { + if !is_global && has_policy { + return validate_update_config_policy(operation, policy_state, policy_signer); + } + if !is_global && has_merge_operations { deny("sandbox policy updates are blocked by provider profile governance") } else { allow() } } +fn validate_update_config_policy( + operation: &Value, + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> InterceptorResult { + let Some(policy) = operation.get("policy") else { + return deny("sandbox policy updates must include a policy payload"); + }; + let Some(annotations) = operation.get("annotations").and_then(Value::as_object) else { + return deny("sandbox policy updates must include governance annotations"); + }; + let Some(signature) = annotations + .get(POLICY_SIGNATURE_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy signature"); + }; + let Some(policy_hash) = annotations + .get(POLICY_HASH_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy hash"); + }; + let Some(policy_signature_kid) = annotations + .get(POLICY_SIGNATURE_KID_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy signing key id"); + }; + if policy_hash != policy_state.policy_hash + || policy_signature_kid != policy_state.policy_signature_kid + { + return deny("sandbox policy update governance annotations are stale"); + } + match validate_signed_policy_payload(policy, signature, policy_state, policy_signer) { + Ok(()) => allow(), + Err(reason) => deny(&reason), + } +} + fn validate_delete_provider_profile() -> InterceptorResult { deny("provider profile deletes are blocked by provider governance") } +fn load_policy_state( + policy_yaml: &str, + policy_signer: &PolicySigner, +) -> Result { + let policy_proto = parse_sandbox_policy(policy_yaml) + .map_err(|err| format!("failed to parse policy YAML: {err}"))?; + let policy = sandbox_policy_to_proto_json(&policy_proto)?; + let policy = normalize_for_struct(policy)?; + let policy_hash = deterministic_policy_hash(&policy_proto); + let policy_signature = policy_signer.sign_policy(&policy_hash)?; + Ok(PolicyState { + policy, + policy_proto, + policy_hash, + policy_signature, + policy_signature_kid: policy_signer.kid().to_string(), + }) +} + fn provider_type(operation: &Value) -> &str { operation .pointer("/provider/type") @@ -672,17 +853,43 @@ fn loaded_profile_ids(profiles: &[LoadedProviderProfile]) -> Vec { .collect() } -fn profile_state_from_loaded(profiles: Vec) -> ProviderProfileState { +fn profile_state_from_loaded( + profiles: Vec, + policy_signer: &PolicySigner, +) -> Result { let ids = loaded_profile_ids(&profiles); let profiles = profiles .into_iter() - .map(|loaded| loaded.profile) - .collect::>(); - ProviderProfileState { + .map(|loaded| sign_provider_profile(loaded.profile, policy_signer)) + .collect::, _>>()?; + Ok(ProviderProfileState { revision: provider_profile_revision(&profiles), ids, profiles, - } + }) +} + +fn sign_provider_profile( + mut profile: ProviderProfile, + policy_signer: &PolicySigner, +) -> Result { + profile.annotations.remove(PROFILE_SIGNATURE_ANNOTATION); + profile.annotations.remove(PROFILE_HASH_ANNOTATION); + profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); + + let profile_hash = deterministic_profile_hash(&profile); + let profile_signature = policy_signer.sign_profile(&profile.id, &profile_hash)?; + profile + .annotations + .insert(PROFILE_HASH_ANNOTATION.to_string(), profile_hash); + profile.annotations.insert( + PROFILE_SIGNATURE_KID_ANNOTATION.to_string(), + policy_signer.kid().to_string(), + ); + profile + .annotations + .insert(PROFILE_SIGNATURE_ANNOTATION.to_string(), profile_signature); + Ok(profile) } fn provider_profile_revision(profiles: &[ProviderProfile]) -> String { @@ -696,6 +903,17 @@ fn provider_profile_revision(profiles: &[ProviderProfile]) -> String { format!("sha256:{:x}", hasher.finalize()) } +fn deterministic_profile_hash(profile: &ProviderProfile) -> String { + let mut profile = profile.clone(); + profile.annotations.remove(PROFILE_SIGNATURE_ANNOTATION); + profile.annotations.remove(PROFILE_HASH_ANNOTATION); + profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-governance-provider-profile-v1"); + hasher.update(profile.encode_to_vec()); + format!("sha256:{:x}", hasher.finalize()) +} + fn is_managed_profile_id(managed_profile_ids: &[String], id: &str) -> bool { managed_profile_ids.iter().any(|managed| managed == id) } @@ -705,16 +923,16 @@ fn format_id_list(ids: &[String]) -> String { } fn providers_are_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { - let Some(Value::Array(providers)) = value else { - return false; + let Some(value) = value else { + return true; }; - if providers.len() != managed_profile_ids.len() { + let Value::Array(providers) = value else { return false; - } - managed_profile_ids.iter().all(|provider| { - providers - .iter() - .any(|value| value.as_str() == Some(provider.as_str())) + }; + providers.iter().all(|provider| { + provider + .as_str() + .is_some_and(|provider| is_managed_profile_id(managed_profile_ids, provider)) }) } @@ -765,14 +983,6 @@ 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) @@ -797,219 +1007,18 @@ fn now_secs() -> i64 { .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)); - - 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 sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Result { + ProtoJsonCodec::openshell() + .and_then(|codec| codec.decode_message_to_json(SANDBOX_POLICY_TYPE, policy)) + .map_err(|err| format!("failed to render policy protobuf JSON: {err}")) } -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 sandbox_policy_from_interceptor_json(policy: &Value) -> Result { + let bytes = ProtoJsonCodec::openshell() + .and_then(|codec| codec.encode_json_to_message(SANDBOX_POLICY_TYPE, policy)) + .map_err(|err| format!("sandbox policy cannot be decoded as protobuf JSON: {err}"))?; + SandboxPolicy::decode(bytes.as_slice()) + .map_err(|err| format!("sandbox policy protobuf payload is invalid: {err}")) } fn struct_to_json(value: &Struct) -> Value { @@ -1076,12 +1085,195 @@ fn proto_value_to_json(value: &ProtoValue) -> Value { } } +fn spawn_policy_watch_worker( + service: GovernanceInterceptorService, + policy_path: PathBuf, + gateway_endpoint: Option, + interval: Duration, +) { + tokio::spawn(async move { + let mut last_seen = policy_file_fingerprint(&policy_path).await.ok(); + loop { + tokio::time::sleep(interval).await; + let fingerprint = match policy_file_fingerprint(&policy_path).await { + Ok(fingerprint) => fingerprint, + Err(err) => { + eprintln!("failed to stat governance policy file: {err}"); + continue; + } + }; + if last_seen.as_ref() == Some(&fingerprint) { + continue; + } + last_seen = Some(fingerprint); + + let policy_yaml = match tokio::fs::read_to_string(&policy_path).await { + Ok(policy_yaml) => policy_yaml, + Err(err) => { + eprintln!( + "failed to read governance policy file {}: {err}", + policy_path.display() + ); + continue; + } + }; + + let policy_state = match service.reload_policy_from_yaml(&policy_yaml) { + Ok(Some(policy_state)) => policy_state, + Ok(None) => continue, + Err(err) => { + eprintln!( + "failed to reload governance policy file {}; keeping previous policy: {err}", + policy_path.display() + ); + continue; + } + }; + + println!("reloaded governance policy {}", policy_state.policy_hash); + if let Some(endpoint) = gateway_endpoint.as_deref() { + if let Err(err) = + propagate_policy_to_running_sandboxes(endpoint, &policy_state).await + { + eprintln!("failed to propagate governance policy reload: {err}"); + } + } else { + println!( + "gateway endpoint not configured; policy reload applies to future sandbox creation only" + ); + } + } + }); +} + +async fn policy_file_fingerprint(path: &Path) -> Result<(SystemTime, u64), String> { + let metadata = tokio::fs::metadata(path) + .await + .map_err(|err| format!("{}: {err}", path.display()))?; + let modified = metadata.modified().unwrap_or(UNIX_EPOCH); + Ok((modified, metadata.len())) +} + +async fn propagate_policy_to_running_sandboxes( + gateway_endpoint: &str, + policy_state: &PolicyState, +) -> Result<(), String> { + let channel = Channel::from_shared(gateway_endpoint.to_string()) + .map_err(|err| format!("invalid gateway endpoint {gateway_endpoint}: {err}"))? + .connect() + .await + .map_err(|err| format!("connect to gateway {gateway_endpoint} failed: {err}"))?; + let mut client = OpenShellClient::new(channel); + let mut offset = 0_u32; + let limit = 100_u32; + let correlation_id = format!("{}:{}", RELOAD_CORRELATION_PREFIX, now_secs()); + loop { + let response = client + .list_sandboxes(ListSandboxesRequest { + limit, + offset, + label_selector: String::new(), + }) + .await + .map_err(|status| format!("list sandboxes failed: {status}"))? + .into_inner(); + let count = response.sandboxes.len(); + for sandbox in response.sandboxes { + if !sandbox_accepts_policy_reload(&sandbox) { + continue; + } + let Some(name) = sandbox_name(&sandbox).filter(|name| !name.is_empty()) else { + continue; + }; + let resource_version = sandbox + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + let result = client + .update_config(UpdateConfigRequest { + name: name.clone(), + policy: Some(policy_state.policy_proto.clone()), + annotations: policy_update_annotations(policy_state, &correlation_id), + expected_resource_version: resource_version, + ..Default::default() + }) + .await; + match result { + Ok(response) => { + println!( + "propagated governance policy reload to sandbox {} version {}", + name, + response.into_inner().version + ); + } + Err(status) if status.code() == Code::InvalidArgument => { + eprintln!( + "governance policy reload rejected for sandbox {name}: {}", + status.message() + ); + } + Err(status) => { + eprintln!("failed to update sandbox {name}: {status}"); + } + } + } + if count < usize::try_from(limit).unwrap_or(usize::MAX) { + break; + } + offset = offset.saturating_add(limit); + } + Ok(()) +} + +fn sandbox_accepts_policy_reload(sandbox: &Sandbox) -> bool { + let phase = sandbox + .status + .as_ref() + .and_then(|status| SandboxPhase::try_from(status.phase).ok()); + matches!( + phase, + Some(SandboxPhase::Ready | SandboxPhase::Provisioning) + ) +} + +fn sandbox_name(sandbox: &Sandbox) -> Option { + sandbox + .metadata + .as_ref() + .map(|metadata| metadata.name.clone()) +} + +fn policy_update_annotations( + policy_state: &PolicyState, + correlation_id: &str, +) -> HashMap { + HashMap::from([ + ( + POLICY_SIGNATURE_ANNOTATION.to_string(), + policy_state.policy_signature.clone(), + ), + ( + POLICY_HASH_ANNOTATION.to_string(), + policy_state.policy_hash.clone(), + ), + ( + POLICY_SIGNATURE_KID_ANNOTATION.to_string(), + policy_state.policy_signature_kid.clone(), + ), + ( + POLICY_RELOAD_CORRELATION_ANNOTATION.to_string(), + correlation_id.to_string(), + ), + ]) +} + #[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 profiles_path: Option = None; let mut gateway_endpoint: Option = None; + let mut policy_watch_interval = Duration::from_millis(DEFAULT_POLICY_WATCH_INTERVAL_MS); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { @@ -1101,9 +1293,19 @@ async fn main() -> Result<(), Box> { let value = args.next().ok_or("--gateway-endpoint requires a URL")?; gateway_endpoint = Some(value); } + "--policy-watch-interval-ms" => { + let value = args + .next() + .ok_or("--policy-watch-interval-ms requires a duration")?; + let millis = value.parse::()?; + if millis == 0 { + return Err("--policy-watch-interval-ms must be greater than zero".into()); + } + policy_watch_interval = Duration::from_millis(millis); + } "-h" | "--help" => { println!( - "usage: governance-interceptor [--listen ADDR] [--policy FILE] [--profiles FILE_OR_DIR] [--gateway-endpoint URL]" + "usage: governance-interceptor [--listen ADDR] [--policy FILE] [--profiles FILE_OR_DIR] [--gateway-endpoint URL] [--policy-watch-interval-ms MS]" ); return Ok(()); } @@ -1111,22 +1313,30 @@ async fn main() -> Result<(), Box> { } } - let policy_yaml = if let Some(path) = policy_path { - tokio::fs::read_to_string(path).await? - } else { - include_str!("../policy.yaml").to_string() - }; + let policy_path = policy_path.unwrap_or_else(default_policy_path); + let policy_yaml = tokio::fs::read_to_string(&policy_path).await?; let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); let service = GovernanceInterceptorService::from_policy_and_profiles_path(&policy_yaml, profiles_path)?; - if let Some(endpoint) = gateway_endpoint { - println!( - "--gateway-endpoint is ignored; provider profiles are vended through the interceptor snapshot API ({endpoint})" - ); + if let Some(endpoint) = &gateway_endpoint { + println!("policy reload propagation enabled through gateway endpoint {endpoint}"); + } else { + println!("policy reload propagation disabled; --gateway-endpoint was not provided"); } let profile_state = service.current_profile_state(); println!("loaded provider profiles: {}", profile_state.ids.join(", ")); + println!( + "loaded governance policy {} from {}", + service.current_policy_state().policy_hash, + policy_path.display() + ); + spawn_policy_watch_worker( + service.clone(), + policy_path, + gateway_endpoint, + policy_watch_interval, + ); println!("governance interceptor listening on {listen}"); Server::builder() @@ -1140,488 +1350,9 @@ fn default_profiles_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles") } -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn service() -> GovernanceInterceptorService { - let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); - GovernanceInterceptorService::from_profiles(profiles).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(), - } - } - - fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec { - service.current_profile_state().ids - } - - fn governed_create_operation( - service: &GovernanceInterceptorService, - policy: Value, - signature: String, - ) -> Value { - let mut operation = json!({ - "spec": { - "policy": policy, - "providers": managed_profile_ids(service), - }, - "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, - 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 service = service(); - let manifest = service.manifest(); - let ids: Vec<_> = manifest - .bindings - .iter() - .map(|binding| binding.id.as_str()) - .collect(); - assert!(ids.contains(&"govern-import-provider-profiles")); - assert!(ids.contains(&"govern-update-provider-profiles")); - assert!(ids.contains(&"govern-delete-provider-profile")); - assert!(ids.contains(&"govern-update-config")); - assert!(ids.contains(&"govern-create-sandbox")); - assert!(!ids.contains(&"govern-attach-provider")); - assert!(!ids.contains(&"govern-detach-provider")); - assert!(!ids.contains(&"govern-update-provider")); - assert!(!ids.contains(&"govern-delete-provider")); - assert_eq!(manifest.failure_policy, "fail_closed"); - assert!(manifest.provider_profiles); - } - - #[tokio::test] - async fn snapshot_provider_profiles_returns_current_profiles() { - let service = service(); - let snapshot = service - .snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})) - .await - .unwrap() - .into_inner(); - assert!(!snapshot.revision.is_empty()); - let profile_ids = snapshot - .profiles - .iter() - .map(|profile| profile.id.as_str()) - .collect::>(); - assert_eq!(profile_ids, vec!["github", "slack"]); - } - - #[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(); - let result = service - .evaluate_inner(&evaluation( - "CreateSandbox", - GatewayInterceptorPhase::ModifyOperation, - json!({"name": "demo", "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(&"/annotations") - || paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature") - ); - let token = signature_patch_token(&result); - assert_eq!(token.split('.').count(), 3); - assert_eq!( - result - .log_annotations - .get("correlation_id") - .map(String::as_str), - Some("governance:create-sandbox:demo") - ); - 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] - 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": managed_profile_ids(&service), - }, - }), - )) - .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, - 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, - 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( - &service, - tampered_policy, - service.policy_signature.clone(), - ), - )) - .unwrap(); - assert!(!result.allowed); - assert!(result.reason.contains("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_vended_profiles() { - let service = service(); - let github = service - .evaluate_inner(&evaluation( - "CreateProvider", - GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "work-github"}, "type": "github"}}), - )) - .unwrap(); - assert!(github.allowed); - - let slack = service - .evaluate_inner(&evaluation( - "CreateProvider", - GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "team-chat"}, "type": "slack"}}), - )) - .unwrap(); - assert!(slack.allowed); - - let teams = service - .evaluate_inner(&evaluation( - "CreateProvider", - GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}), - )) - .unwrap(); - assert!(!teams.allowed); - assert!( - teams - .reason - .contains("providers may only use vended provider profiles") - ); - } - - #[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!(!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] - fn provider_update_and_delete_are_not_governed() { - let service = service(); - let update = service - .evaluate_inner(&evaluation( - "UpdateProvider", - GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "slack"}}}), - )) - .unwrap(); - assert!(update.allowed); - - let delete = service - .evaluate_inner(&evaluation( - "DeleteProvider", - GatewayInterceptorPhase::Validate, - json!({"name": "github"}), - )) - .unwrap(); - assert!(delete.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"}]}), - json!({"name": "demo", "merge_operations": [{"op": "add"}]}), - ] { - let result = service - .evaluate_inner(&evaluation( - "UpdateConfig", - GatewayInterceptorPhase::Validate, - operation, - )) - .unwrap(); - assert!(!result.allowed); - } - - let settings_update = service - .evaluate_inner(&evaluation( - "UpdateConfig", - GatewayInterceptorPhase::Validate, - json!({"global": true, "settingKey": "providers_v2_enabled"}), - )) - .unwrap(); - assert!(settings_update.allowed); - - let global_policy_update = service - .evaluate_inner(&evaluation( - "UpdateConfig", - GatewayInterceptorPhase::Validate, - json!({"global": true, "policy": {"version": 1}}), - )) - .unwrap(); - assert!(global_policy_update.allowed); - - let mut sandbox_policy_sync = evaluation( - "UpdateConfig", - GatewayInterceptorPhase::Validate, - json!({"name": "demo", "policy": {"version": 1}}), - ); - sandbox_policy_sync - .principal - .insert("kind".to_string(), "sandbox".to_string()); - sandbox_policy_sync - .principal - .insert("sandbox_id".to_string(), "demo-id".to_string()); - let sandbox_policy_sync = service.evaluate_inner(&sandbox_policy_sync).unwrap(); - assert!(sandbox_policy_sync.allowed); - } +fn default_policy_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("policy.yaml") } + +#[cfg(test)] +mod tests; diff --git a/examples/governance-interceptor/src/policy_hash.rs b/examples/governance-interceptor/src/policy_hash.rs new file mode 100644 index 0000000000..18a45bb9f0 --- /dev/null +++ b/examples/governance-interceptor/src/policy_hash.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_core::proto::SandboxPolicy; +use prost::Message as _; +use sha2::{Digest, Sha256}; + +/// Compute a deterministic SHA-256 hash of a sandbox policy. +/// +/// Protobuf binary encoding is not canonical for maps, so this hashes scalar +/// fields directly and sorts `network_policies` by key before hashing each +/// encoded rule. +pub(crate) fn deterministic_policy_hash(policy: &SandboxPolicy) -> String { + let mut hasher = Sha256::new(); + hasher.update(policy.version.to_le_bytes()); + if let Some(filesystem) = &policy.filesystem { + hasher.update(filesystem.encode_to_vec()); + } + if let Some(landlock) = &policy.landlock { + hasher.update(landlock.encode_to_vec()); + } + if let Some(process) = &policy.process { + hasher.update(process.encode_to_vec()); + } + let mut entries: Vec<_> = policy.network_policies.iter().collect(); + entries.sort_by_key(|(key, _)| key.as_str()); + for (key, value) in entries { + hasher.update(key.as_bytes()); + hasher.update(value.encode_to_vec()); + } + hex_encode(&hasher.finalize()) +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + #[test] + fn sorts_network_policy_map_keys() { + let left = policy_with_network_rules(&[ + ("beta", "beta.example.com"), + ("alpha", "alpha.example.com"), + ]); + let right = policy_with_network_rules(&[ + ("alpha", "alpha.example.com"), + ("beta", "beta.example.com"), + ]); + assert_eq!( + deterministic_policy_hash(&left), + deterministic_policy_hash(&right) + ); + + let changed = policy_with_network_rules(&[ + ("alpha", "alpha.example.com"), + ("beta", "changed.example.com"), + ]); + assert_ne!( + deterministic_policy_hash(&left), + deterministic_policy_hash(&changed) + ); + } + + fn policy_with_network_rules(rules: &[(&str, &str)]) -> SandboxPolicy { + SandboxPolicy { + version: 1, + network_policies: rules + .iter() + .map(|(key, host)| { + ( + (*key).to_string(), + NetworkPolicyRule { + name: (*key).to_string(), + endpoints: vec![NetworkEndpoint { + host: (*host).to_string(), + port: 443, + ..NetworkEndpoint::default() + }], + ..NetworkPolicyRule::default() + }, + ) + }) + .collect(), + ..SandboxPolicy::default() + } + } +} diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs new file mode 100644 index 0000000000..4b190b4486 --- /dev/null +++ b/examples/governance-interceptor/src/tests.rs @@ -0,0 +1,658 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use serde_json::json; + +fn service() -> GovernanceInterceptorService { + let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); + GovernanceInterceptorService::from_profiles(profiles).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(), + } +} + +fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec { + service.current_profile_state().ids +} + +fn policy_state(service: &GovernanceInterceptorService) -> PolicyState { + service.current_policy_state() +} + +fn assert_signed_profile(service: &GovernanceInterceptorService, profile: &ProviderProfile) { + let profile_hash = profile + .annotations + .get(PROFILE_HASH_ANNOTATION) + .expect("profile hash annotation"); + assert_eq!(profile_hash, &deterministic_profile_hash(profile)); + assert_eq!( + profile + .annotations + .get(PROFILE_SIGNATURE_KID_ANNOTATION) + .map(String::as_str), + Some(service.policy_signer.kid()) + ); + let profile_signature = profile + .annotations + .get(PROFILE_SIGNATURE_ANNOTATION) + .expect("profile signature annotation"); + service + .policy_signer + .verify_profile_signature(profile_signature, &profile.id, profile_hash) + .expect("profile signature verifies"); +} + +fn governed_create_operation( + service: &GovernanceInterceptorService, + policy: Value, + signature: String, +) -> Value { + governed_create_operation_with_providers(policy, signature, managed_profile_ids(service)) +} + +fn governed_create_operation_with_providers( + policy: Value, + signature: String, + providers: Vec, +) -> Value { + let mut operation = json!({ + "spec": { + "policy": policy, + "providers": providers, + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(signature), + ); + operation +} + +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") +} + +fn policy_yaml_with_dynamic_rule() -> String { + let policy = include_str!("../policy.yaml"); + let changed = policy + .replace("api-1.example.com", "api-2.example.com") + .replace("api.example.com", "api.changed.example.com"); + if changed != policy { + return changed; + } + + policy.replace( + "network_policies: {}", + r#"network_policies: + example_api: +name: example-api +endpoints: +- host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only"#, + ) +} + +#[test] +fn manifest_declares_governance_bindings() { + let service = service(); + let manifest = service.manifest(); + let ids: Vec<_> = manifest + .bindings + .iter() + .map(|binding| binding.id.as_str()) + .collect(); + assert!(ids.contains(&"govern-import-provider-profiles")); + assert!(ids.contains(&"govern-update-provider-profiles")); + assert!(ids.contains(&"govern-delete-provider-profile")); + assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-create-sandbox")); + assert!(!ids.contains(&"govern-attach-provider")); + assert!(!ids.contains(&"govern-detach-provider")); + assert!(!ids.contains(&"govern-update-provider")); + assert!(!ids.contains(&"govern-delete-provider")); + assert_eq!(manifest.failure_policy, "fail_closed"); + assert!(manifest.provider_profiles); +} + +#[tokio::test] +async fn snapshot_provider_profiles_returns_current_profiles() { + let service = service(); + let snapshot = service + .snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})) + .await + .unwrap() + .into_inner(); + assert!(!snapshot.revision.is_empty()); + let profile_ids = snapshot + .profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(); + assert_eq!(profile_ids, vec!["github", "slack"]); + for profile in &snapshot.profiles { + assert_signed_profile(&service, profile); + } +} + +#[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_and_signature_without_replacing_providers() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::ModifyOperation, + json!({ + "name": "demo", + "spec": {"providers": ["github"]}, + "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(&"/annotations") + || paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature") + ); + let token = signature_patch_token(&result); + assert_eq!(token.split('.').count(), 3); + assert_eq!( + result + .log_annotations + .get("correlation_id") + .map(String::as_str), + Some("governance:create-sandbox:demo") + ); + 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] +fn create_sandbox_validate_accepts_selected_provider_subset() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation_with_providers( + state.policy.clone(), + state.policy_signature.clone(), + vec!["github".to_string()], + ), + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn create_sandbox_validate_accepts_missing_provider_list() { + let service = service(); + let state = policy_state(&service); + let mut operation = json!({ + "spec": { + "policy": state.policy.clone(), + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(state.policy_signature.clone()), + ); + + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn create_sandbox_validate_denies_unmanaged_provider() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation_with_providers( + state.policy.clone(), + state.policy_signature.clone(), + vec!["github".to_string(), "teams".to_string()], + ), + )) + .unwrap(); + assert!(!result.allowed); + assert!( + result + .reason + .contains("sandbox providers may only use vended provider profiles") + ); +} + +#[test] +fn create_sandbox_validate_denies_missing_signature() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + json!({ + "spec": { + "policy": state.policy, + "providers": managed_profile_ids(&service), + }, + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("missing")); +} + +#[test] +fn create_sandbox_validate_denies_malformed_signature() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(&service, state.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 governance_state = policy_state(&governance); + let other_state = policy_state(&other); + let result = governance + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation( + &governance, + governance_state.policy.clone(), + other_state.policy_signature, + ), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); +} + +#[test] +fn create_sandbox_validate_denies_signed_policy_mismatch() { + let service = service(); + let state = policy_state(&service); + let mut tampered_policy = state.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(&service, tampered_policy, state.policy_signature.clone()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); +} + +#[test] +fn policy_patch_uses_protobuf_json_names() { + let service = service(); + let state = policy_state(&service); + assert!(state.policy.get("filesystem").is_some()); + assert!(state.policy.get("networkPolicies").is_some()); + assert!(state.policy.get("filesystem_policy").is_none()); + assert!(state.policy.get("network_policies").is_none()); +} + +#[test] +fn policy_reload_updates_hash_and_preserves_last_valid_state_on_error() { + let service = service(); + let before = policy_state(&service); + let changed = service + .reload_policy_from_yaml(&policy_yaml_with_dynamic_rule()) + .unwrap() + .expect("policy hash should change"); + assert_ne!(before.policy_hash, changed.policy_hash); + assert_eq!(policy_state(&service).policy_hash, changed.policy_hash); + + let err = service + .reload_policy_from_yaml("version: not-a-number") + .expect_err("invalid policy should be rejected"); + assert!(err.contains("failed to parse policy YAML")); + assert_eq!(policy_state(&service).policy_hash, changed.policy_hash); +} + +#[test] +fn signed_governance_policy_update_is_allowed() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": state.policy.clone(), + "annotations": policy_update_annotations(&state, "governance:reload-policy:test"), + }), + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn stale_governance_policy_update_is_denied_after_reload() { + let service = service(); + let stale = policy_state(&service); + let changed = service + .reload_policy_from_yaml(&policy_yaml_with_dynamic_rule()) + .unwrap() + .expect("policy hash should change"); + assert_ne!(stale.policy_hash, changed.policy_hash); + + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": stale.policy.clone(), + "annotations": policy_update_annotations(&stale, "governance:reload-policy:stale"), + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("stale")); +} + +#[test] +fn provider_creation_is_limited_to_vended_profiles() { + let service = service(); + let github = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "work-github"}, "type": "github"}}), + )) + .unwrap(); + assert!(github.allowed); + + let slack = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "team-chat"}, "type": "slack"}}), + )) + .unwrap(); + assert!(slack.allowed); + + let teams = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}), + )) + .unwrap(); + assert!(!teams.allowed); + assert!( + teams + .reason + .contains("providers may only use vended provider profiles") + ); +} + +#[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!(!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] +fn provider_update_and_delete_are_not_governed() { + let service = service(); + let update = service + .evaluate_inner(&evaluation( + "UpdateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}}}), + )) + .unwrap(); + assert!(update.allowed); + + let delete = service + .evaluate_inner(&evaluation( + "DeleteProvider", + GatewayInterceptorPhase::Validate, + json!({"name": "github"}), + )) + .unwrap(); + assert!(delete.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"}]}), + json!({"name": "demo", "merge_operations": [{"op": "add"}]}), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + } + + let settings_update = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"global": true, "settingKey": "providers_v2_enabled"}), + )) + .unwrap(); + assert!(settings_update.allowed); + + let global_policy_update = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"global": true, "policy": {"version": 1}}), + )) + .unwrap(); + assert!(global_policy_update.allowed); + + let mut sandbox_policy_sync = evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": {"version": 1}}), + ); + sandbox_policy_sync + .principal + .insert("kind".to_string(), "sandbox".to_string()); + sandbox_policy_sync + .principal + .insert("sandbox_id".to_string(), "demo-id".to_string()); + let sandbox_policy_sync = service.evaluate_inner(&sandbox_policy_sync).unwrap(); + assert!(sandbox_policy_sync.allowed); +} diff --git a/proto/openshell.proto b/proto/openshell.proto index 4b19c366c9..f849464222 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1098,6 +1098,8 @@ message ProviderProfile { // profile files use 0. Gateway responses set this for stored custom profiles. // Update calls use this for optimistic concurrency. uint64 resource_version = 10; + // Optional non-secret annotations attached by profile sources or importers. + map annotations = 11; } // Stored custom provider profile object. From 3f95d80dc0b56b9988f6f12695d8d301fedd9fa7 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 9 Jul 2026 20:51:23 -0700 Subject: [PATCH 13/27] fix(providers): use configured profile sources for refresh updates Signed-off-by: Drew Newberry --- crates/openshell-server/src/grpc/provider.rs | 147 +++++++++++++++++- .../src/provider_profile_sources.rs | 21 +++ 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index b241375924..928b68bc44 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -192,6 +192,7 @@ pub(super) async fn list_provider_records( .collect()) } +#[cfg(test)] pub(super) async fn update_provider_record( store: &Store, provider: Provider, @@ -2237,7 +2238,12 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record_with_catalog( + state.store.as_ref(), + &state.provider_profile_sources, + updated, + ) + .await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2333,7 +2339,12 @@ pub(super) async fn handle_delete_provider_refresh( 0, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record_with_catalog( + state.store.as_ref(), + &state.provider_profile_sources, + updated, + ) + .await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -3816,6 +3827,138 @@ mod tests { ); } + async fn state_with_authoritative_profiles_over_default_grants() -> Arc { + let state = test_server_state().await; + let store = state.store.as_ref(); + import_token_grant_profile(&state, "grant-a", "api.example.com", 443, "/v1/**").await; + import_token_grant_profile(&state, "grant-b", "api.example.com", 443, "/v1/**").await; + create_empty_token_grant_provider(store, "provider-a", "grant-a").await; + create_empty_token_grant_provider(store, "provider-b", "grant-b").await; + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-authoritative-profiles-id".to_string(), + name: "sandbox-authoritative-profiles".to_string(), + 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()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let mut state = Arc::into_inner(state).expect("test server state should be uniquely owned"); + state.provider_profile_sources = ProviderProfileSources::from_test_profiles(vec![ + custom_profile("grant-a"), + custom_profile("grant-b"), + ]); + Arc::new(state) + } + + #[tokio::test] + async fn configure_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + + handle_configure_provider_refresh( + &state, + Request::new(ConfigureProviderRefreshRequest { + provider: "provider-a".to_string(), + credential_key: "REFRESH_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: Some(expires_at_ms), + }), + ) + .await + .expect("authoritative profiles should govern the provider expiry update"); + + let provider = state + .store + .get_message_by_name::("provider-a") + .await + .unwrap() + .expect("provider-a"); + assert_eq!( + provider.credential_expires_at_ms.get("REFRESH_TOKEN"), + Some(&expires_at_ms) + ); + } + + #[tokio::test] + async fn delete_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + let updated = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-a".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), + }; + let provider = update_provider_record_with_catalog( + state.store.as_ref(), + &state.provider_profile_sources, + updated, + ) + .await + .unwrap(); + let refresh_state = crate::provider_refresh::new_refresh_state( + &provider, + "REFRESH_TOKEN", + crate::provider_refresh::NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms, + token_url: String::new(), + scopes: Vec::new(), + refresh_before_seconds: 0, + max_lifetime_seconds: 0, + }, + ) + .unwrap(); + crate::provider_refresh::put_refresh_state(state.store.as_ref(), &refresh_state) + .await + .unwrap(); + + handle_delete_provider_refresh( + &state, + Request::new(DeleteProviderRefreshRequest { + provider: "provider-a".to_string(), + credential_key: "REFRESH_TOKEN".to_string(), + }), + ) + .await + .expect("authoritative profiles should govern the provider expiry update"); + + let provider = state + .store + .get_message_by_name::("provider-a") + .await + .unwrap() + .expect("provider-a"); + assert!( + !provider + .credential_expires_at_ms + .contains_key("REFRESH_TOKEN") + ); + } + #[tokio::test] async fn configure_provider_refresh_accepts_vertex_service_account_token_key() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index ade1143135..9c90dc3d30 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -95,6 +95,8 @@ enum ConfiguredProviderProfileSource { Builtin(BuiltinProviderProfileSource), User(UserProviderProfileSource), Interceptors(openshell_gateway_interceptors::GatewayInterceptorRuntime), + #[cfg(test)] + Static(ProviderProfileSnapshot), } #[derive(Debug, Clone)] @@ -139,6 +141,21 @@ impl ProviderProfileSources { Self::with_default_sources() } + #[cfg(test)] + pub(crate) fn from_test_profiles(profiles: Vec) -> Self { + Self { + sources: vec![ConfiguredProviderProfileSource::Static( + ProviderProfileSnapshot { + source_id: "test".to_string(), + revision: profile_snapshot_revision(&profiles), + profiles, + user_managed: false, + allow_empty: false, + }, + )], + } + } + pub async fn list_profiles(&self, store: &Store) -> Result, Status> { let catalog = self.effective_profiles(store).await?; Ok(catalog @@ -250,6 +267,10 @@ impl ProviderProfileSources { })?; snapshots.extend(external.into_iter().map(interceptor_snapshot)); } + #[cfg(test)] + ConfiguredProviderProfileSource::Static(snapshot) => { + snapshots.push(snapshot.clone()); + } } } Ok(snapshots) From 0625ae6bd0a746aa9a46cd7253642bbb001d0a6e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 9 Jul 2026 21:53:48 -0700 Subject: [PATCH 14/27] feat(gateway-interceptors): add phase-specific evaluation payloads Signed-off-by: Drew Newberry --- architecture/gateway.md | 11 ++ .../openshell-gateway-interceptors/src/lib.rs | 130 +++++++++++++----- .../src/routes.rs | 26 +++- crates/openshell-server/src/multiplex.rs | 99 ++++++++++++- examples/governance-interceptor/src/main.rs | 61 ++++---- examples/governance-interceptor/src/tests.rs | 60 +++++++- proto/gateway_interceptor.proto | 35 ++++- 7 files changed, 352 insertions(+), 70 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index ec6c5f2755..0305738e9e 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,6 +52,17 @@ 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. +Each interceptor evaluation selects exactly one phase payload: +`modify_operation`, `validate`, or `post_commit`. Modification and validation +payloads carry the protobuf JSON operation entering that phase. Post-commit +payloads carry the successful committed response instead of echoing the +request. Only the `validate` payload can also carry optional read-only +`current_state`; modification and post-commit evaluations never receive it. The +gateway does not yet load method-specific state, so the field remains absent; +an absent state is distinct from an explicitly empty object. Method-specific +state schemas and persistence-version binding are deferred until a concrete +consumer requires them. + Interceptor manifests can also vend provider profile catalogs. The gateway always starts with the in-tree built-in catalog source, then merges any interceptor-declared sources. An authoritative interceptor catalog becomes the diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 8bc0b30b4f..9eee5a7cd7 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -27,8 +27,9 @@ use openshell_core::config::{ use openshell_core::proto::ProviderProfile; use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, - InterceptorResult, InterceptorSelector, JsonPatch, ProviderProfileSnapshotRequest, - gateway_interceptor_client::GatewayInterceptorClient, + InterceptorResult, InterceptorSelector, JsonPatch, ModifyOperationEvaluation, + PostCommitEvaluation, ProviderProfileSnapshotRequest, ValidateEvaluation, + gateway_interceptor_client::GatewayInterceptorClient, interceptor_evaluation, }; use prost::Message; use prost_types::{ @@ -111,15 +112,6 @@ impl Phase { Self::PostCommit => "post_commit", } } - - #[must_use] - pub const fn to_proto(self) -> GatewayInterceptorPhase { - match self { - Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, - Self::Validate => GatewayInterceptorPhase::Validate, - Self::PostCommit => GatewayInterceptorPhase::PostCommit, - } - } } impl TryFrom for Phase { @@ -239,14 +231,13 @@ pub struct GatewayInterceptorRuntime { #[derive(Debug, Clone)] pub struct EvaluationContext { pub principal: BTreeMap, - pub current_state: Option, + pub validate_current_state: Option, } #[derive(Debug, Clone)] pub struct InterceptedRequest { pub body: Vec, selector: RpcSelector, - operation: Value, } #[derive(Debug, Clone)] @@ -505,28 +496,40 @@ impl GatewayInterceptorRuntime { .encode() .map_err(|err| Status::invalid_argument(err.to_string()))?; - Ok(InterceptedRequest { - body, - selector, - operation, - }) + Ok(InterceptedRequest { body, selector }) } pub async fn evaluate_post_commit( &self, intercepted: &InterceptedRequest, + response_body: &[u8], context: &EvaluationContext, ) -> std::result::Result<(), Status> { + let output_type = self + .routes + .output_type(&intercepted.selector.service, &intercepted.selector.method) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))?; + let frame = GrpcFrame::decode(response_body)?; + let committed_response = self + .descriptors + .decode_message_to_json(output_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; self.evaluate_phase( &intercepted.selector, Phase::PostCommit, - intercepted.operation.clone(), + committed_response, context, ) .await .map(|_| ()) } + #[must_use] + pub fn has_post_commit(&self, intercepted: &InterceptedRequest) -> bool { + self.bindings + .contains_key(&(intercepted.selector.clone(), Phase::PostCommit)) + } + async fn evaluate_phase( &self, selector: &RpcSelector, @@ -868,21 +871,22 @@ async fn evaluate_plan( 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 current_state = if plan.phase == Phase::Validate { + context + .validate_current_state + .clone() + .map(json_to_struct) + .transpose()? + } else { + None + }; 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(), + phase: Some(phase_evaluation(plan.phase, operation, current_state)), }; let start = Instant::now(); @@ -906,6 +910,27 @@ async fn evaluate_plan( Ok(result) } +fn phase_evaluation( + phase: Phase, + proposed_operation: Struct, + current_state: Option, +) -> interceptor_evaluation::Phase { + match phase { + Phase::ModifyOperation => { + interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { + proposed_operation: Some(proposed_operation), + }) + } + Phase::Validate => interceptor_evaluation::Phase::Validate(ValidateEvaluation { + proposed_operation: Some(proposed_operation), + current_state, + }), + Phase::PostCommit => interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { + committed_response: Some(proposed_operation), + }), + } +} + fn apply_failure_policy( plan: &BindingPlan, err: &InterceptorError, @@ -1061,18 +1086,18 @@ struct GrpcFrame { 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")); + return Err(Status::invalid_argument("gRPC frame is too short")); } let compressed = body[0] != 0; if compressed { return Err(Status::unimplemented( - "gateway interceptors do not support compressed gRPC requests", + "gateway interceptors do not support compressed gRPC frames", )); } 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", + "gRPC body must contain exactly one frame", )); } Ok(Self { @@ -2140,6 +2165,49 @@ mod tests { ); } + #[test] + fn phase_evaluations_use_the_matching_oneof_variant() { + let operation = json_to_struct(json!({"name": "demo"})).unwrap(); + + let modify = phase_evaluation(Phase::ModifyOperation, operation.clone(), None); + let interceptor_evaluation::Phase::ModifyOperation(payload) = modify else { + panic!("expected modify_operation payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + + let validate = phase_evaluation(Phase::Validate, operation.clone(), None); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + assert!(payload.current_state.is_none()); + + let validate = + phase_evaluation(Phase::Validate, operation.clone(), Some(Struct::default())); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(Struct::default())); + + let current_state = json_to_struct(json!({"resourceVersion": "7"})).unwrap(); + let validate = phase_evaluation( + Phase::Validate, + operation.clone(), + Some(current_state.clone()), + ); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(current_state.clone())); + + let post_commit = + phase_evaluation(Phase::PostCommit, operation.clone(), Some(current_state)); + let interceptor_evaluation::Phase::PostCommit(payload) = post_commit else { + panic!("expected post_commit payload"); + }; + assert_eq!(payload.committed_response, Some(operation)); + } + #[tokio::test] async fn evaluation_log_emits_structured_log_annotations() { let log_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs index 6ddaa08a95..65698d82e8 100644 --- a/crates/openshell-gateway-interceptors/src/routes.rs +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -54,6 +54,7 @@ pub struct OpenShellRouteIndex { all_methods: BTreeSet, unary_methods: BTreeSet, input_types: BTreeMap, + output_types: BTreeMap, } impl OpenShellRouteIndex { @@ -63,6 +64,7 @@ impl OpenShellRouteIndex { let mut all_methods = BTreeSet::new(); let mut unary_methods = BTreeSet::new(); let mut input_types = BTreeMap::new(); + let mut output_types = BTreeMap::new(); for file in &set.file { if file.package.as_deref() != Some("openshell.v1") { @@ -85,8 +87,16 @@ impl OpenShellRouteIndex { .strip_prefix('.') .unwrap_or_else(|| method.input_type.as_deref().unwrap_or_default()) .to_string(); + let output_type = method + .output_type + .as_deref() + .unwrap_or_default() + .strip_prefix('.') + .unwrap_or_else(|| method.output_type.as_deref().unwrap_or_default()) + .to_string(); unary_methods.insert(name.clone()); - input_types.insert(name, input_type); + input_types.insert(name.clone(), input_type); + output_types.insert(name, output_type); } } } @@ -96,6 +106,7 @@ impl OpenShellRouteIndex { all_methods, unary_methods, input_types, + output_types, }; index.validate_non_interceptable_list()?; Ok(index) @@ -117,6 +128,15 @@ impl OpenShellRouteIndex { } } + #[must_use] + pub fn output_type(&self, service: &str, method: &str) -> Option<&str> { + if service == SERVICE_OPEN_SHELL && self.unary_methods.contains(method) { + self.output_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 { @@ -150,5 +170,9 @@ mod tests { assert!(index.is_interceptable("openshell.v1.OpenShell", "UpdateConfig")); assert!(!index.is_interceptable("openshell.v1.OpenShell", "GetSandbox")); assert!(!index.is_interceptable("openshell.v1.OpenShell", "WatchSandbox")); + assert_eq!( + index.output_type("openshell.v1.OpenShell", "CreateSandbox"), + Some("openshell.v1.SandboxResponse") + ); } } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index ee7cfaab5f..dd9b5646de 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, LengthLimitError, Limited}; +use http_body_util::{BodyExt, Full, LengthLimitError, Limited, StreamBody}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo, TokioTimer}, @@ -295,9 +295,20 @@ where ); let response = inner.ready().await?.call(req).await?; - if grpc_status_from_response(&response) == "0" + if grpc_status_from_response(&response) != "0" + || !interceptors.has_post_commit(&intercepted) + { + return Ok(response); + } + + let (response, response_body, trailers) = + match collect_intercepted_grpc_response(response).await { + Ok(response) => response, + Err(status) => return Ok(status.into_http()), + }; + if grpc_status_from_response_and_trailers(&response, trailers.as_ref()) == "0" && let Err(status) = interceptors - .evaluate_post_commit(&intercepted, &context) + .evaluate_post_commit(&intercepted, &response_body, &context) .await { return Ok(status.into_http()); @@ -333,12 +344,41 @@ fn boxed_body_from_bytes(bytes: Bytes) -> BoxBody { BoxBody(body) } +async fn collect_intercepted_grpc_response( + response: Response, +) -> Result<(Response, Bytes, Option), tonic::Status> { + let (parts, body) = response.into_parts(); + let collected = body.collect().await.map_err(|err| { + tonic::Status::internal(format!( + "failed to read gRPC response for post-commit evaluation: {err}" + )) + })?; + let trailers = collected.trailers().cloned(); + let bytes = collected.to_bytes(); + let body = tonic_body_from_bytes_and_trailers(bytes.clone(), trailers.clone()); + Ok((Response::from_parts(parts, body), bytes, trailers)) +} + +fn tonic_body_from_bytes_and_trailers( + bytes: Bytes, + trailers: Option, +) -> tonic::body::Body { + let mut frames: Vec, tonic::Status>> = Vec::with_capacity(2); + if !bytes.is_empty() { + frames.push(Ok(http_body::Frame::data(bytes))); + } + if let Some(trailers) = trailers { + frames.push(Ok(http_body::Frame::trailers(trailers))); + } + tonic::body::Body::new(StreamBody::new(futures::stream::iter(frames))) +} + fn gateway_interceptor_context(extensions: &Extensions) -> EvaluationContext { EvaluationContext { principal: extensions .get::() .map_or_else(unknown_gateway_principal, gateway_principal_fields), - current_state: None, + validate_current_state: None, } } @@ -908,6 +948,17 @@ fn grpc_status_from_response(res: &Response) -> String { .map_or_else(|| "0".to_string(), ToString::to_string) } +fn grpc_status_from_response_and_trailers( + res: &Response, + trailers: Option<&http::HeaderMap>, +) -> String { + trailers + .and_then(|trailers| trailers.get("grpc-status")) + .or_else(|| res.headers().get("grpc-status")) + .and_then(|value| value.to_str().ok()) + .map_or_else(|| "0".to_string(), ToString::to_string) +} + fn normalize_http_path(path: &str) -> &'static str { match path { p if p.starts_with("/_ws_tunnel") => "/_ws_tunnel", @@ -1073,6 +1124,46 @@ mod tests { assert_eq!(status.code(), tonic::Code::ResourceExhausted); } + #[tokio::test] + async fn intercepted_grpc_response_preserves_body_and_trailers() { + let bytes = Bytes::from_static(b"committed-response"); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("0")); + let response = Response::new(tonic_body_from_bytes_and_trailers( + bytes.clone(), + Some(trailers.clone()), + )); + + let (response, observed, observed_trailers) = + collect_intercepted_grpc_response(response).await.unwrap(); + + assert_eq!(observed, bytes); + assert_eq!(observed_trailers.as_ref(), Some(&trailers)); + assert_eq!( + grpc_status_from_response_and_trailers(&response, observed_trailers.as_ref()), + "0" + ); + + let collected = response.into_body().collect().await.unwrap(); + assert_eq!(collected.trailers(), Some(&trailers)); + assert_eq!(collected.to_bytes(), bytes); + } + + #[test] + fn grpc_trailer_status_takes_precedence_over_headers() { + let response = Response::builder() + .header("grpc-status", "0") + .body(()) + .unwrap(); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("7")); + + assert_eq!( + grpc_status_from_response_and_trailers(&response, Some(&trailers)), + "7" + ); + } + #[tokio::test] async fn http_response_includes_request_id() { let addr = start_http_server_with_middleware().await; diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index e41500b367..39f9528e75 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -17,6 +17,7 @@ use openshell_core::proto::gateway_interceptor::v1::{ InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, ProviderProfileSnapshot, ProviderProfileSnapshotRequest, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + interceptor_evaluation, }; use openshell_core::proto::{ ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, @@ -31,7 +32,7 @@ use prost_types::ListValue; use prost_types::{Struct, Value as ProtoValue, value::Kind}; use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Number, Value, json}; +use serde_json::{Number, Value, json}; use sha2::{Digest, Sha256}; use tonic::Code; use tonic::transport::{Channel, Server}; @@ -319,40 +320,52 @@ impl GovernanceInterceptorService { ) -> Result { let profile_state = self.current_profile_state(); let policy_state = self.current_policy_state(); - let phase = GatewayInterceptorPhase::try_from(evaluation.phase) - .map_err(|_| Status::invalid_argument("unknown interceptor phase"))?; - let operation = evaluation - .operation + let phase = evaluation + .phase .as_ref() - .map(struct_to_json) - .unwrap_or_else(|| Value::Object(Map::new())); + .ok_or_else(|| Status::invalid_argument("interceptor phase is required"))?; + let proposed_operation = match phase { + interceptor_evaluation::Phase::ModifyOperation(payload) => { + payload.proposed_operation.as_ref() + } + interceptor_evaluation::Phase::Validate(payload) => payload.proposed_operation.as_ref(), + interceptor_evaluation::Phase::PostCommit(payload) => { + payload.committed_response.as_ref() + } + } + .ok_or_else(|| Status::invalid_argument("phase payload is required"))?; + let operation = struct_to_json(proposed_operation); match (evaluation.method.as_str(), phase) { - ("CreateSandbox", GatewayInterceptorPhase::ModifyOperation) => { + ("CreateSandbox", interceptor_evaluation::Phase::ModifyOperation(_)) => { Self::patch_create_sandbox(&operation, &policy_state) } - ("CreateSandbox", GatewayInterceptorPhase::Validate) => Ok(validate_create_sandbox( - &operation, - &profile_state.ids, - &policy_state, - &self.policy_signer, - )), - ("CreateProvider", GatewayInterceptorPhase::Validate) => { + ("CreateSandbox", interceptor_evaluation::Phase::Validate(_)) => { + Ok(validate_create_sandbox( + &operation, + &profile_state.ids, + &policy_state, + &self.policy_signer, + )) + } + ("CreateProvider", interceptor_evaluation::Phase::Validate(_)) => { Ok(self.validate_create_provider(&operation, &profile_state.ids)) } - ("UpdateConfig", GatewayInterceptorPhase::Validate) => Ok(validate_update_config( - &operation, - &evaluation.principal, - &policy_state, - &self.policy_signer, - )), - ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { + ("UpdateConfig", interceptor_evaluation::Phase::Validate(_)) => { + Ok(validate_update_config( + &operation, + &evaluation.principal, + &policy_state, + &self.policy_signer, + )) + } + ("ImportProviderProfiles", interceptor_evaluation::Phase::Validate(_)) => { Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) } - ("UpdateProviderProfiles", GatewayInterceptorPhase::Validate) => { + ("UpdateProviderProfiles", interceptor_evaluation::Phase::Validate(_)) => { Ok(self.validate_update_provider_profiles(&operation, &profile_state.ids)) } - ("DeleteProviderProfile", GatewayInterceptorPhase::Validate) => { + ("DeleteProviderProfile", interceptor_evaluation::Phase::Validate(_)) => { Ok(validate_delete_provider_profile()) } _ => Ok(allow()), diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 4b190b4486..ade8baed88 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use openshell_core::proto::gateway_interceptor::v1::{ + ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation, +}; use serde_json::json; fn service() -> GovernanceInterceptorService { @@ -14,15 +17,33 @@ fn evaluation( phase: GatewayInterceptorPhase, operation: Value, ) -> InterceptorEvaluation { + let proposed_operation = Some(json_to_struct(&operation).unwrap()); + let phase = match phase { + GatewayInterceptorPhase::ModifyOperation => { + interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { + proposed_operation, + }) + } + GatewayInterceptorPhase::Validate => { + interceptor_evaluation::Phase::Validate(ValidateEvaluation { + proposed_operation, + current_state: None, + }) + } + GatewayInterceptorPhase::PostCommit => { + interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { + committed_response: proposed_operation, + }) + } + GatewayInterceptorPhase::Unspecified => panic!("test evaluation phase must be specified"), + }; 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(), + phase: Some(phase), } } @@ -34,6 +55,39 @@ fn policy_state(service: &GovernanceInterceptorService) -> PolicyState { service.current_policy_state() } +#[test] +fn evaluation_requires_a_phase_payload() { + let service = service(); + let mut request = evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({}), + ); + request.phase = None; + + let error = service.evaluate_inner(&request).unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.message(), "interceptor phase is required"); +} + +#[test] +fn evaluation_requires_a_proposed_operation() { + let service = service(); + let mut request = evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({}), + ); + let Some(interceptor_evaluation::Phase::Validate(payload)) = request.phase.as_mut() else { + panic!("expected validate payload"); + }; + payload.proposed_operation = None; + + let error = service.evaluate_inner(&request).unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.message(), "phase payload is required"); +} + fn assert_signed_profile(service: &GovernanceInterceptorService, profile: &ProviderProfile) { let profile_hash = profile .annotations diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 357f559500..ab80124e8a 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -45,14 +45,35 @@ message InterceptorEvaluation { 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; + map principal = 5; + + // Exactly one phase payload is set for each evaluation. + oneof phase { + // Evaluation that may modify the prepared gateway operation. + ModifyOperationEvaluation modify_operation = 6; + // Evaluation that may reject, but not mutate, the prepared operation. + ValidateEvaluation validate = 7; + // Evaluation after the gateway operation has completed successfully. + PostCommitEvaluation post_commit = 8; + } +} + +message ModifyOperationEvaluation { + // Gateway-prepared operation the gateway proposes to execute. + google.protobuf.Struct proposed_operation = 1; +} + +message ValidateEvaluation { + // Gateway-prepared operation after earlier interceptor modifications. + google.protobuf.Struct proposed_operation = 1; + // Optional read-only gateway state loaded before applying the operation. + google.protobuf.Struct current_state = 2; +} + +message PostCommitEvaluation { + // Protobuf JSON-shaped response returned by the committed gateway operation. + google.protobuf.Struct committed_response = 1; } message InterceptorResult { From 1eed7294eede1e6e0a0986918cb965285cbbddda Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 10 Jul 2026 11:25:54 -0700 Subject: [PATCH 15/27] feat(providers): compose provider profile sources Signed-off-by: Drew Newberry --- architecture/gateway.md | 15 +- crates/openshell-core/src/config.rs | 46 +- crates/openshell-core/src/lib.rs | 2 +- .../openshell-gateway-interceptors/src/lib.rs | 148 +++-- crates/openshell-server/src/cli.rs | 6 + crates/openshell-server/src/config_file.rs | 30 +- crates/openshell-server/src/lib.rs | 17 +- .../src/provider_profile_sources.rs | 623 +++++++++++++++--- docs/reference/gateway-config.mdx | 20 + docs/sandboxes/providers-v2.mdx | 2 +- examples/governance-interceptor/README.md | 26 +- examples/governance-interceptor/smoke.sh | 5 + 12 files changed, 780 insertions(+), 160 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 0305738e9e..8908303329 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -63,12 +63,15 @@ an absent state is distinct from an explicitly empty object. Method-specific state schemas and persistence-version binding are deferred until a concrete consumer requires them. -Interceptor manifests can also vend provider profile catalogs. The gateway -always starts with the in-tree built-in catalog source, then merges any -interceptor-declared sources. An authoritative interceptor catalog becomes the -visible provider profile source of truth for that gateway and hides built-in -and user-imported profiles from profile resolution, while append catalogs add -static profiles alongside the built-in/user catalog. +Interceptor manifests can also vend provider profile catalogs. Gateway +configuration selects the exact ordered source set from the in-tree built-in +source, the stored user source, and named profile-capable interceptors. Omitting +the setting selects `builtin + user`; selecting only an interceptor makes it +authoritative by omission. Every selected source uses the same snapshot, +semantic-validation, and duplicate-detection path. Duplicate normalized profile +IDs fail instead of creating source precedence. The gateway treats configured +interceptors as trusted sources and does not verify signature annotations in +their profile payloads. Supported auth modes: diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 691176be4c..e5a894d602 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -387,6 +387,9 @@ pub struct Config { /// Disabled-by-default gateway interceptor service configs. pub gateway_interceptors: Vec, + /// Ordered provider-profile sources used to build the effective catalog. + pub provider_profile_sources: 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; @@ -580,6 +583,18 @@ pub struct GatewayInterceptorConfig { pub bindings: Vec, } +/// One configured source in the gateway's effective provider-profile catalog. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum GatewayProviderProfileSourceConfig { + /// Profiles bundled with the `OpenShell` build. + Builtin, + /// Profiles managed through the provider profile mutation APIs. + User, + /// Profiles vended by a configured gateway interceptor instance. + Interceptor { name: String }, +} + /// Failure behavior when an interceptor evaluation cannot produce a valid /// result. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -686,6 +701,10 @@ impl Config { oidc: None, auth: GatewayAuthConfig::default(), gateway_interceptors: Vec::new(), + provider_profile_sources: vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + ], mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, database_url: String::new(), @@ -786,6 +805,16 @@ impl Config { self } + /// Set the ordered provider-profile sources used by the gateway. + #[must_use] + pub fn with_provider_profile_sources(mut self, sources: I) -> Self + where + I: IntoIterator, + { + self.provider_profile_sources = sources.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)> { @@ -911,8 +940,9 @@ mod tests { use super::is_reachable_unix_socket; use super::{ ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorFailurePolicy, - GatewayJwtConfig, detect_driver, docker_host_unix_socket_path, is_unix_socket, - normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, detect_driver, + docker_host_unix_socket_path, is_unix_socket, normalize_compute_driver_name, + podman_socket_candidates_from_env, podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -978,6 +1008,18 @@ mod tests { assert!(!cfg.auth.allow_unauthenticated_users); } + #[test] + fn config_defaults_to_builtin_and_user_provider_profile_sources() { + let cfg = Config::new(None); + assert_eq!( + cfg.provider_profile_sources, + vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + ] + ); + } + #[test] fn gateway_jwt_ttl_defaults_to_non_expiring() { let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({ diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 8830bce9ad..e9128fc53d 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -40,7 +40,7 @@ pub mod time; pub use config::{ ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, - GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 9eee5a7cd7..15dfa28e79 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -223,7 +223,7 @@ impl std::fmt::Debug for BindingPlan { #[derive(Debug, Clone)] pub struct GatewayInterceptorRuntime { bindings: Arc>>, - profile_sources: Arc>, + profile_sources: Arc>, routes: Arc, descriptors: Arc, } @@ -242,22 +242,21 @@ pub struct InterceptedRequest { #[derive(Debug, Clone)] pub struct ProviderProfileSourceSnapshot { - pub source_id: String, pub revision: String, pub profiles: Vec, } #[derive(Clone)] -struct ProfileSourcePlan { +pub struct GatewayInterceptorProfileSource { interceptor_name: String, source_id: String, timeout: Duration, client: GatewayInterceptorClient, } -impl std::fmt::Debug for ProfileSourcePlan { +impl std::fmt::Debug for GatewayInterceptorProfileSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ProfileSourcePlan") + f.debug_struct("GatewayInterceptorProfileSource") .field("interceptor_name", &self.interceptor_name) .field("source_id", &self.source_id) .field("timeout", &self.timeout) @@ -278,6 +277,7 @@ pub async fn initialize( impl GatewayInterceptorRuntime { async fn build(mut configs: Vec) -> Result { + validate_interceptor_configs(&configs)?; configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); let routes = @@ -285,11 +285,9 @@ impl GatewayInterceptorRuntime { let descriptors = ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); - let mut profile_sources = Vec::new(); - let mut profile_source_ids = BTreeSet::new(); + let mut profile_sources = BTreeMap::new(); 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)?, @@ -371,18 +369,16 @@ impl GatewayInterceptorRuntime { if manifest.provider_profiles { let source_id = format!("interceptor/{}", config.name); - if !profile_source_ids.insert(source_id.clone()) { - return Err(InterceptorError::Config(format!( - "duplicate provider profile source id '{source_id}'" - ))); - } - profile_sources.push(ProfileSourcePlan { - interceptor_name: config.name.clone(), - source_id, - timeout, - client: GatewayInterceptorClient::new(channel.clone()) - .max_decoding_message_size(max_response_bytes), - }); + profile_sources.insert( + config.name.clone(), + GatewayInterceptorProfileSource { + interceptor_name: config.name.clone(), + source_id, + timeout, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }, + ); } } @@ -401,45 +397,11 @@ impl GatewayInterceptorRuntime { } #[must_use] - pub fn has_profile_sources(&self) -> bool { - !self.profile_sources.is_empty() - } - - pub async fn provider_profile_snapshots(&self) -> Result> { - let mut snapshots = Vec::with_capacity(self.profile_sources.len()); - for source in self.profile_sources.iter() { - let mut client = source.client.clone(); - let response = tokio::time::timeout( - source.timeout, - client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), - ) - .await - .map_err(|_| { - InterceptorError::Transport(format!( - "SnapshotProviderProfiles timed out for '{}'", - source.interceptor_name - )) - })? - .map_err(|status| { - InterceptorError::Transport(format!( - "SnapshotProviderProfiles failed for '{}': {status}", - source.interceptor_name - )) - })? - .into_inner(); - - let revision = if response.revision.trim().is_empty() { - provider_profile_snapshot_revision(&response.profiles) - } else { - response.revision - }; - snapshots.push(ProviderProfileSourceSnapshot { - source_id: source.source_id.clone(), - revision, - profiles: response.profiles, - }); - } - Ok(snapshots) + pub fn provider_profile_source( + &self, + interceptor_name: &str, + ) -> Option { + self.profile_sources.get(interceptor_name).cloned() } #[must_use] @@ -589,6 +551,45 @@ impl GatewayInterceptorRuntime { } } +impl GatewayInterceptorProfileSource { + #[must_use] + pub fn source_id(&self) -> &str { + &self.source_id + } + + pub async fn snapshot(&self) -> Result { + let mut client = self.client.clone(); + let response = tokio::time::timeout( + self.timeout, + client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), + ) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles timed out for '{}'", + self.interceptor_name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles failed for '{}': {status}", + self.interceptor_name + )) + })? + .into_inner(); + + let revision = if response.revision.trim().is_empty() { + provider_profile_snapshot_revision(&response.profiles) + } else { + response.revision + }; + Ok(ProviderProfileSourceSnapshot { + revision, + profiles: response.profiles, + }) + } +} + #[derive(Debug, Clone)] struct NormalizedBinding { binding_id: String, @@ -657,6 +658,20 @@ fn validate_service_config(config: &GatewayInterceptorConfig) -> Result<()> { Ok(()) } +fn validate_interceptor_configs(configs: &[GatewayInterceptorConfig]) -> Result<()> { + let mut names = BTreeSet::new(); + for config in configs { + validate_service_config(config)?; + if !names.insert(config.name.clone()) { + return Err(InterceptorError::Config(format!( + "duplicate interceptor instance name '{}'", + config.name + ))); + } + } + Ok(()) +} + fn normalize_binding( interceptor_name: &str, binding: &InterceptorBinding, @@ -2141,6 +2156,21 @@ mod tests { ); } + #[test] + fn duplicate_interceptor_instance_names_are_invalid() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + ..GatewayInterceptorConfig::default() + }; + let err = validate_interceptor_configs(&[config.clone(), config]).unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: duplicate interceptor instance name 'governance'" + ); + } + #[test] fn binding_failure_policy_rejects_ignore() { let overrides = Vec::new(); diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 0df81504a8..7260fc1fdf 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -379,6 +379,12 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result, #[serde(default)] + pub provider_profile_sources: Option>, + #[serde(default)] pub mtls_auth: Option, #[serde(default)] pub gateway_jwt: Option, @@ -406,6 +408,30 @@ allow_unauthenticated_users = true assert!(auth.allow_unauthenticated_users); } + #[test] + fn parses_provider_profile_source_composition() { + let toml = r#" +[openshell.gateway] +provider_profile_sources = [ + { type = "builtin" }, + { type = "user" }, + { type = "interceptor", name = "provider-governance" }, +] +"#; + let tmp = write_tmp(toml); + let file = load(tmp.path()).expect("valid provider profile sources parse"); + assert_eq!( + file.openshell.gateway.provider_profile_sources, + Some(vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + GatewayProviderProfileSourceConfig::Interceptor { + name: "provider-governance".to_string(), + }, + ]) + ); + } + #[test] fn rejects_database_url_in_file() { let toml = r#" diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 7a7a67696f..d40364523c 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -281,10 +281,19 @@ pub(crate) async fn run_server( .map_err(|e| { Error::config(format!("gateway interceptor initialization failed: {e}")) })?; - let provider_profile_sources = - provider_profile_sources::ProviderProfileSources::from_gateway_interceptors( - gateway_interceptors.clone(), - ); + let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( + &config.provider_profile_sources, + gateway_interceptors.as_ref(), + ) + .map_err(|err| { + Error::config(format!( + "provider profile source configuration failed: {err}" + )) + })?; + info!( + sources = ?provider_profile_sources.source_ids(), + "provider profile sources configured" + ); let mut state = ServerState::new( config.clone(), store.clone(), diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 9c90dc3d30..84e2d4323f 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -4,10 +4,15 @@ //! Gateway-local provider profile sources. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use async_trait::async_trait; +use openshell_core::GatewayProviderProfileSourceConfig; use openshell_core::proto::{ProviderProfile, StoredProviderProfile}; -use openshell_gateway_interceptors::ProviderProfileSourceSnapshot as InterceptorProfileSnapshot; +use openshell_gateway_interceptors::{ + GatewayInterceptorProfileSource, GatewayInterceptorRuntime, + ProviderProfileSourceSnapshot as InterceptorProfileSnapshot, +}; use openshell_providers::{ ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, validate_profile_set, @@ -29,15 +34,15 @@ impl ObjectType for StoredProviderProfile { #[derive(Debug, Clone)] pub struct ProviderProfileSnapshot { - source_id: String, revision: String, profiles: Vec, - user_managed: bool, - allow_empty: bool, } #[async_trait] -pub trait ProviderProfileSource: Send + Sync { +pub trait ProviderProfileSource: Send + Sync + std::fmt::Debug { + fn source_id(&self) -> &str; + fn user_managed(&self) -> bool; + fn allow_empty(&self) -> bool; async fn snapshot(&self, store: &Store) -> Result; } @@ -46,17 +51,26 @@ struct BuiltinProviderProfileSource; #[async_trait] impl ProviderProfileSource for BuiltinProviderProfileSource { + fn source_id(&self) -> &str { + BUILTIN_SOURCE_ID + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + async fn snapshot(&self, _store: &Store) -> Result { let profiles = builtin_profiles() .iter() .map(ProviderTypeProfile::to_proto) .collect::>(); Ok(ProviderProfileSnapshot { - source_id: BUILTIN_SOURCE_ID.to_string(), revision: profile_snapshot_revision(&profiles), profiles, - user_managed: false, - allow_empty: false, }) } } @@ -66,6 +80,18 @@ struct UserProviderProfileSource; #[async_trait] impl ProviderProfileSource for UserProviderProfileSource { + fn source_id(&self) -> &str { + USER_SOURCE_ID + } + + fn user_managed(&self) -> bool { + true + } + + fn allow_empty(&self) -> bool { + true + } + async fn snapshot(&self, store: &Store) -> Result { let stored = user_provider_profiles(store).await?; let mut profiles = Vec::new(); @@ -81,27 +107,50 @@ impl ProviderProfileSource for UserProviderProfileSource { } } Ok(ProviderProfileSnapshot { - source_id: USER_SOURCE_ID.to_string(), revision: format!("sha256:{:x}", hasher.finalize()), profiles, - user_managed: true, - allow_empty: true, }) } } -#[derive(Debug, Clone)] -enum ConfiguredProviderProfileSource { - Builtin(BuiltinProviderProfileSource), - User(UserProviderProfileSource), - Interceptors(openshell_gateway_interceptors::GatewayInterceptorRuntime), - #[cfg(test)] - Static(ProviderProfileSnapshot), +#[async_trait] +impl ProviderProfileSource for GatewayInterceptorProfileSource { + fn source_id(&self) -> &str { + Self::source_id(self) + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + let InterceptorProfileSnapshot { revision, profiles } = + Self::snapshot(self).await.map_err(|err| { + Status::unavailable(format!( + "provider profile source '{}' snapshot failed: {err}", + self.source_id() + )) + })?; + Ok(ProviderProfileSnapshot { revision, profiles }) + } } #[derive(Debug, Clone)] pub struct ProviderProfileSources { - sources: Vec, + sources: Vec>, +} + +#[derive(Debug, Clone)] +struct CollectedProviderProfileSnapshot { + source_id: String, + revision: String, + profiles: Vec, + user_managed: bool, + allow_empty: bool, } #[derive(Debug, Clone)] @@ -122,37 +171,70 @@ impl ProviderProfileSources { pub fn with_default_sources() -> Self { Self { sources: vec![ - ConfiguredProviderProfileSource::Builtin(BuiltinProviderProfileSource), - ConfiguredProviderProfileSource::User(UserProviderProfileSource), + Arc::new(BuiltinProviderProfileSource), + Arc::new(UserProviderProfileSource), ], } } - pub fn from_gateway_interceptors( - runtime: Option, - ) -> Self { - if let Some(runtime) = runtime - && runtime.has_profile_sources() - { - return Self { - sources: vec![ConfiguredProviderProfileSource::Interceptors(runtime)], + pub fn from_config( + configured: &[GatewayProviderProfileSourceConfig], + runtime: Option<&GatewayInterceptorRuntime>, + ) -> Result { + if configured.is_empty() { + return Err("provider_profile_sources must contain at least one source".to_string()); + } + + let mut source_ids = BTreeSet::new(); + let mut sources: Vec> = Vec::with_capacity(configured.len()); + for source in configured { + let source: Arc = match source { + GatewayProviderProfileSourceConfig::Builtin => { + Arc::new(BuiltinProviderProfileSource) + } + GatewayProviderProfileSourceConfig::User => Arc::new(UserProviderProfileSource), + GatewayProviderProfileSourceConfig::Interceptor { name } => { + if name.trim().is_empty() { + return Err("provider profile interceptor source name must not be empty" + .to_string()); + } + let source = runtime + .and_then(|runtime| runtime.provider_profile_source(name)) + .ok_or_else(|| { + format!( + "provider profile source interceptor '{name}' is not configured or does not advertise provider_profiles" + ) + })?; + Arc::new(source) + } }; + let source_id = source.source_id().to_string(); + if !source_ids.insert(source_id.clone()) { + return Err(format!( + "duplicate provider profile source '{source_id}' in provider_profile_sources" + )); + } + sources.push(source); } - Self::with_default_sources() + Ok(Self { sources }) + } + + pub fn source_ids(&self) -> Vec<&str> { + self.sources + .iter() + .map(|source| source.source_id()) + .collect() } #[cfg(test)] pub(crate) fn from_test_profiles(profiles: Vec) -> Self { Self { - sources: vec![ConfiguredProviderProfileSource::Static( - ProviderProfileSnapshot { - source_id: "test".to_string(), + sources: vec![Arc::new(StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot { revision: profile_snapshot_revision(&profiles), profiles, - user_managed: false, - allow_empty: false, }, - )], + })], } } @@ -249,49 +331,30 @@ impl ProviderProfileSources { build_effective_profiles(snapshots) } - async fn snapshots(&self, store: &Store) -> Result, Status> { - let mut snapshots = Vec::new(); + async fn snapshots( + &self, + store: &Store, + ) -> Result, Status> { + let mut snapshots = Vec::with_capacity(self.sources.len()); for source in &self.sources { - match source { - ConfiguredProviderProfileSource::Builtin(source) => { - snapshots.push(source.snapshot(store).await?); - } - ConfiguredProviderProfileSource::User(source) => { - snapshots.push(source.snapshot(store).await?); - } - ConfiguredProviderProfileSource::Interceptors(runtime) => { - let external = runtime.provider_profile_snapshots().await.map_err(|err| { - Status::unavailable(format!( - "provider profile source snapshot failed: {err}" - )) - })?; - snapshots.extend(external.into_iter().map(interceptor_snapshot)); - } - #[cfg(test)] - ConfiguredProviderProfileSource::Static(snapshot) => { - snapshots.push(snapshot.clone()); - } - } + let snapshot = source.snapshot(store).await?; + snapshots.push(CollectedProviderProfileSnapshot { + source_id: source.source_id().to_string(), + revision: snapshot.revision, + profiles: snapshot.profiles, + user_managed: source.user_managed(), + allow_empty: source.allow_empty(), + }); } Ok(snapshots) } } -fn interceptor_snapshot(snapshot: InterceptorProfileSnapshot) -> ProviderProfileSnapshot { - ProviderProfileSnapshot { - source_id: snapshot.source_id, - revision: snapshot.revision, - profiles: snapshot.profiles, - user_managed: false, - allow_empty: false, - } -} - fn build_effective_profiles( - snapshots: Vec, + snapshots: Vec, ) -> Result { let mut source_ids = BTreeSet::new(); - let mut profiles = BTreeMap::new(); + let mut profiles: BTreeMap = BTreeMap::new(); for snapshot in snapshots { let source_id = snapshot.source_id.trim(); @@ -330,9 +393,17 @@ fn build_effective_profiles( profile.id, source_id )) })?; - if profiles.contains_key(&id) { + if let Some(existing) = profiles.get(&id) { + let location = if existing.source_id == source_id { + format!("within source '{source_id}'") + } else { + format!( + "across configured sources '{}' and '{source_id}'", + existing.source_id + ) + }; return Err(Status::failed_precondition(format!( - "duplicate provider profile id '{id}' across configured sources" + "duplicate provider profile id '{id}' {location}" ))); } profiles.insert( @@ -435,9 +506,112 @@ pub fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { .map_or(0, |metadata| metadata.resource_version) } +#[cfg(test)] +#[derive(Debug, Clone)] +struct StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot, +} + +#[cfg(test)] +#[async_trait] +impl ProviderProfileSource for StaticProviderProfileSource { + fn source_id(&self) -> &'static str { + "test" + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + Ok(self.snapshot.clone()) + } +} + #[cfg(test)] mod tests { use super::*; + use openshell_core::GatewayInterceptorConfig; + use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, InterceptorEvaluation, InterceptorManifest, InterceptorResult, + ProviderProfileSnapshot as ProtoProviderProfileSnapshot, ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + }; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::{Request, Response}; + + #[derive(Clone)] + struct MockProfileInterceptor { + advertises_profiles: bool, + snapshot: ProtoProviderProfileSnapshot, + } + + #[tonic::async_trait] + impl GatewayInterceptor for MockProfileInterceptor { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(InterceptorManifest { + name: "mock-profile-source".to_string(), + provider_profiles: self.advertises_profiles, + ..InterceptorManifest::default() + })) + } + + async fn evaluate( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(InterceptorResult { + allowed: true, + ..InterceptorResult::default() + })) + } + + async fn snapshot_provider_profiles( + &self, + _request: Request, + ) -> Result, Status> { + if self.snapshot.revision == "test:unavailable" { + return Err(Status::unavailable("mock profile source unavailable")); + } + Ok(Response::new(self.snapshot.clone())) + } + } + + async fn interceptor_runtime( + snapshot: ProtoProviderProfileSnapshot, + advertises_profiles: bool, + ) -> (GatewayInterceptorRuntime, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(MockProfileInterceptor { + advertises_profiles, + snapshot, + })) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: format!("http://{address}"), + ..GatewayInterceptorConfig::default() + }]) + .await + .unwrap() + .unwrap(); + (runtime, task) + } fn profile(id: &str) -> ProviderProfile { let mut profile = builtin_profiles() @@ -453,14 +627,14 @@ mod tests { #[test] fn duplicate_profile_ids_across_sources_are_invalid() { let err = build_effective_profiles(vec![ - ProviderProfileSnapshot { + CollectedProviderProfileSnapshot { source_id: "source-a".to_string(), revision: "a".to_string(), profiles: vec![profile("github")], user_managed: false, allow_empty: false, }, - ProviderProfileSnapshot { + CollectedProviderProfileSnapshot { source_id: "source-b".to_string(), revision: "b".to_string(), profiles: vec![profile("github")], @@ -473,9 +647,308 @@ mod tests { assert!(err.message().contains("duplicate provider profile id")); } + #[test] + fn configured_local_sources_preserve_order() { + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::User, + GatewayProviderProfileSourceConfig::Builtin, + ], + None, + ) + .unwrap(); + + assert_eq!(sources.source_ids(), vec!["user", "builtin"]); + } + + #[test] + fn configured_sources_must_not_be_empty() { + let err = ProviderProfileSources::from_config(&[], None).unwrap_err(); + assert!(err.contains("at least one source")); + } + + #[test] + fn configured_sources_must_be_unique() { + let err = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Builtin, + ], + None, + ) + .unwrap_err(); + assert!(err.contains("duplicate provider profile source 'builtin'")); + } + + #[test] + fn configured_interceptor_must_advertise_profile_capability() { + let err = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + None, + ) + .unwrap_err(); + assert!(err.contains("not configured or does not advertise provider_profiles")); + } + + #[test] + fn source_that_disallows_empty_snapshots_fails_closed() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "empty".to_string(), + profiles: Vec::new(), + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!(err.message().contains("returned no profiles")); + } + + #[test] + fn user_source_may_return_an_empty_snapshot() { + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "empty".to_string(), + profiles: Vec::new(), + user_managed: true, + allow_empty: true, + }]) + .unwrap(); + + assert!(catalog.profiles.is_empty()); + } + + #[test] + fn invalid_profile_semantics_fail_closed() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "invalid".to_string(), + profiles: vec![profile("GitHub")], + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!( + err.message() + .contains("provider profile source 'interceptor/test' is invalid") + ); + } + + #[tokio::test] + async fn interceptor_snapshot_passes_through_adapter_and_validation_boundary() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: String::new(), + profiles: vec![profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let profiles = sources.list_profiles(&store).await.unwrap(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].id, "github"); + let snapshot = runtime + .provider_profile_source("governance") + .unwrap() + .snapshot() + .await + .unwrap(); + assert!(snapshot.revision.starts_with("sha256:")); + task.abort(); + } + + #[tokio::test] + async fn empty_interceptor_snapshot_received_over_adapter_fails_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "empty".to_string(), + profiles: Vec::new(), + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.list_profiles(&store).await.unwrap_err(); + assert!(err.message().contains("returned no profiles")); + task.abort(); + } + + #[tokio::test] + async fn invalid_interceptor_snapshot_received_over_adapter_fails_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "invalid".to_string(), + profiles: vec![profile("GitHub")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.list_profiles(&store).await.unwrap_err(); + assert!(err.message().contains("is invalid")); + task.abort(); + } + + #[tokio::test] + async fn distinct_local_and_interceptor_profiles_compose() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "external".to_string(), + profiles: vec![profile("governed-github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let profiles = sources.list_profiles(&store).await.unwrap(); + assert!(profiles.iter().any(|profile| profile.id == "github")); + assert!( + profiles + .iter() + .any(|profile| profile.id == "governed-github") + ); + task.abort(); + } + + #[tokio::test] + async fn duplicate_profile_ids_across_local_and_interceptor_sources_fail_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "external".to_string(), + profiles: vec![profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.list_profiles(&store).await.unwrap_err(); + assert!( + err.message() + .contains("duplicate provider profile id 'github'") + ); + task.abort(); + } + + #[tokio::test] + async fn duplicate_profiles_within_interceptor_snapshot_fail_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "duplicates".to_string(), + profiles: vec![profile("github"), profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.list_profiles(&store).await.unwrap_err(); + assert!(err.message().contains("duplicate provider profile id")); + task.abort(); + } + + #[tokio::test] + async fn unavailable_selected_interceptor_does_not_fall_back() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "test:unavailable".to_string(), + profiles: vec![profile("governed-github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.list_profiles(&store).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unavailable); + task.abort(); + } + + #[tokio::test] + async fn interceptor_without_profile_capability_cannot_be_selected() { + let (runtime, task) = + interceptor_runtime(ProtoProviderProfileSnapshot::default(), false).await; + let err = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap_err(); + + assert!(err.contains("does not advertise provider_profiles")); + task.abort(); + } + #[test] fn source_managed_profiles_report_static_source() { - let catalog = build_effective_profiles(vec![ProviderProfileSnapshot { + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { source_id: "interceptor/test".to_string(), revision: "test".to_string(), profiles: vec![profile("slack")], diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 07f6025316..ea5f6662c3 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -104,6 +104,13 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 +# Optional exact provider-profile source composition. When omitted, the +# gateway uses builtin + user. +provider_profile_sources = [ + { type = "builtin" }, + { type = "user" }, +] + # Gateway listener TLS (distinct from the per-driver guest_tls_*). [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" @@ -161,6 +168,19 @@ 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. +`provider_profile_sources` selects the exact ordered provider-profile source set. Omit it to use the built-in and user-managed sources. Use `{ type = "builtin" }`, `{ type = "user" }`, and `{ type = "interceptor", name = "" }` entries to compose a catalog. An interceptor entry must reference a configured service whose manifest advertises `provider_profiles = true`. Selecting only an interceptor makes that catalog authoritative; include local sources explicitly to compose them. Empty or duplicate source lists, unknown interceptor names, and duplicate normalized profile IDs fail closed. Source order controls collection and diagnostics, not override precedence. + +For an authoritative interceptor catalog, select only that interceptor: + +```toml +[openshell.gateway] +provider_profile_sources = [ + { type = "interceptor", name = "provider-governance" }, +] +``` + +The gateway validates snapshot structure and provider-profile semantics. It treats a configured interceptor as the source trust boundary and does not verify signature, hash, or key annotations in profile payloads. + `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/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index e934ff301e..c3c84b8383 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -52,7 +52,7 @@ The feature flag controls provider-derived policy layers. OpenShell still suppor Providers v2 currently includes these user-facing features: - Built-in provider profiles loaded by the gateway by default. -- Gateway interceptors can vend append or authoritative provider profile catalogs for governed deployments. +- Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. - `openshell provider list-profiles` with table, YAML, and JSON output. - `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 36b3f84ec6..887749e94a 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -57,21 +57,27 @@ 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. The interceptor advertises `provider_profiles = true` in its manifest and vends -the current profile set through `SnapshotProviderProfiles`. While the -interceptor is attached, the gateway uses that snapshot as the profile source: -`provider list-profiles` shows only `github` and `slack`, and built-in/user -sources are omitted. The example signs each profile's canonical protobuf -payload and exposes the JWT under +the current profile set through `SnapshotProviderProfiles`. The gateway config +selects the interceptor as its only provider profile source, so +`provider list-profiles` shows only `github` and `slack`; built-in and user +sources are omitted. The example signs each profile's canonical protobuf payload +and exposes the JWT under `annotations["openshell.nvidia.com/profile-signature"]`; the signed hash and key -ID are exposed beside it. Valid edits to files under `profiles/` change the -profile signature and snapshot revision, so running sandboxes that use the -edited provider profile reload their effective provider-derived policy through -the normal gateway config polling path. Invalid edits keep the last valid -snapshot active. +ID are exposed beside it. These annotations demonstrate logic an interceptor +can own; the gateway treats them as opaque metadata and does not verify them. +Valid edits to files under `profiles/` change the profile signature and snapshot +revision, so running sandboxes that use the edited provider profile reload their +effective provider-derived policy through the normal gateway config polling +path. Invalid edits keep the last valid snapshot active. Gateway TOML snippet: ```toml +[openshell.gateway] +provider_profile_sources = [ + { type = "interceptor", name = "provider-governance" }, +] + [[openshell.gateway.interceptors]] name = "provider-governance" grpc_endpoint = "http://127.0.0.1:18081" diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index e5f070d616..a51f99d5d8 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -325,6 +325,11 @@ write_gateway_config() { [openshell] version = 1 +[openshell.gateway] +provider_profile_sources = [ + { type = "interceptor", name = "provider-governance" }, +] + [openshell.gateway.auth] allow_unauthenticated_users = true From 7a8ce8cdad45cf8393773348241a64a74867ffa3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 10 Jul 2026 17:36:29 -0700 Subject: [PATCH 16/27] fix(gateway-interceptors): preserve committed responses Signed-off-by: Drew Newberry --- architecture/gateway.md | 9 + .../openshell-gateway-interceptors/src/lib.rs | 63 +++++ crates/openshell-server/src/multiplex.rs | 237 ++++++++++++++++-- docs/reference/gateway-config.mdx | 2 + proto/gateway_interceptor.proto | 2 + 5 files changed, 291 insertions(+), 22 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 8908303329..46067476fc 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -63,6 +63,15 @@ an absent state is distinct from an explicitly empty object. Method-specific state schemas and persistence-version binding are deferred until a concrete consumer requires them. +Post-commit evaluation is strictly observational. A binding that includes +`post_commit` must resolve to `fail_open`, or interceptor initialization fails. +After a handler returns success, failures never replace the committed response. +Binding failures emit the standard fail-open warning and counter; response +observation or evaluation failures outside binding policy emit warnings and the +`openshell_gateway_interceptor_post_commit_observation_failures_total` metric. +The gateway reconstructs the original response frames, including trailers and +body errors, before evaluating the observer. + Interceptor manifests can also vend provider profile catalogs. Gateway configuration selects the exact ordered source set from the in-tree built-in source, the stored user source, and named profile-capable interceptors. Omitting diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 15dfa28e79..95a326e29d 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -735,6 +735,13 @@ fn normalize_binding( } } + if phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", + failure_policy.as_str() + ))); + } + Ok(Some(NormalizedBinding { binding_id: binding_id.to_string(), selector, @@ -2195,6 +2202,62 @@ mod tests { ); } + #[test] + fn post_commit_binding_rejects_fail_closed() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_closed".to_string(), + }; + + let err = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect_err("post_commit must not fail closed after an operation commits"); + + assert_eq!( + err.to_string(), + "invalid interceptor config: interceptor 'audit' binding 'audit-create-sandbox' uses failure_policy=fail_closed for post_commit; post_commit must use fail_open" + ); + } + + #[test] + fn post_commit_binding_accepts_fail_open() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_open".to_string(), + }; + + let normalized = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect("fail-open post_commit binding should be valid") + .expect("binding should be enabled"); + + assert_eq!(normalized.failure_policy, FailurePolicy::FailOpen); + } + #[test] fn phase_evaluations_use_the_matching_oneof_variant() { let operation = json_to_struct(json!({"name": "demo"})).unwrap(); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index dd9b5646de..5c1f9dad4f 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -6,7 +6,7 @@ //! This module implements connection-level multiplexing that routes requests //! to either the gRPC service or HTTP endpoints based on the request headers. -use bytes::Bytes; +use bytes::{Bytes, BytesMut}; use http::{Extensions, HeaderValue, Request, Response}; use http_body::Body; use http_body_util::{BodyExt, Full, LengthLimitError, Limited, StreamBody}; @@ -32,7 +32,7 @@ use std::time::{Duration, Instant}; use tokio::io::{AsyncRead, AsyncWrite}; use tower::ServiceExt; use tower_http::request_id::{MakeRequestId, RequestId}; -use tracing::Span; +use tracing::{Span, warn}; use crate::{ OpenShellService, ServerState, @@ -301,17 +301,36 @@ where return Ok(response); } - let (response, response_body, trailers) = - match collect_intercepted_grpc_response(response).await { - Ok(response) => response, - Err(status) => return Ok(status.into_http()), - }; + let (response, observation) = observe_intercepted_grpc_response(response).await; + let (response_body, trailers) = match observation { + Ok(observation) => observation, + Err(error) => { + warn!( + error = %error, + "gateway post-commit response observation failed; preserving committed response" + ); + counter!( + "openshell_gateway_interceptor_post_commit_observation_failures_total", + "stage" => "response_body" + ) + .increment(1); + return Ok(response); + } + }; if grpc_status_from_response_and_trailers(&response, trailers.as_ref()) == "0" && let Err(status) = interceptors .evaluate_post_commit(&intercepted, &response_body, &context) .await { - return Ok(status.into_http()); + warn!( + error = %status, + "gateway post-commit evaluation failed; preserving committed response" + ); + counter!( + "openshell_gateway_interceptor_post_commit_observation_failures_total", + "stage" => "evaluation" + ) + .increment(1); } Ok(response) @@ -344,21 +363,47 @@ fn boxed_body_from_bytes(bytes: Bytes) -> BoxBody { BoxBody(body) } -async fn collect_intercepted_grpc_response( +async fn observe_intercepted_grpc_response( response: Response, -) -> Result<(Response, Bytes, Option), tonic::Status> { - let (parts, body) = response.into_parts(); - let collected = body.collect().await.map_err(|err| { - tonic::Status::internal(format!( - "failed to read gRPC response for post-commit evaluation: {err}" - )) - })?; - let trailers = collected.trailers().cloned(); - let bytes = collected.to_bytes(); - let body = tonic_body_from_bytes_and_trailers(bytes.clone(), trailers.clone()); - Ok((Response::from_parts(parts, body), bytes, trailers)) +) -> ( + Response, + Result<(Bytes, Option), String>, +) { + let (parts, mut body) = response.into_parts(); + let mut frames = Vec::new(); + let mut bytes = BytesMut::new(); + let mut trailers = None; + + while let Some(frame) = body.frame().await { + match frame { + Ok(frame) => { + if let Some(data) = frame.data_ref() { + bytes.extend_from_slice(data); + } + if let Some(frame_trailers) = frame.trailers_ref() { + trailers = Some(frame_trailers.clone()); + } + frames.push(Ok(frame)); + } + Err(status) => { + let error = + format!("failed to read gRPC response for post-commit evaluation: {status}"); + frames.push(Err(status)); + return ( + Response::from_parts(parts, tonic_body_from_frames(frames)), + Err(error), + ); + } + } + } + + ( + Response::from_parts(parts, tonic_body_from_frames(frames)), + Ok((bytes.freeze(), trailers)), + ) } +#[cfg(test)] fn tonic_body_from_bytes_and_trailers( bytes: Bytes, trailers: Option, @@ -370,6 +415,12 @@ fn tonic_body_from_bytes_and_trailers( if let Some(trailers) = trailers { frames.push(Ok(http_body::Frame::trailers(trailers))); } + tonic_body_from_frames(frames) +} + +fn tonic_body_from_frames( + frames: Vec, tonic::Status>>, +) -> tonic::body::Body { tonic::body::Body::new(StreamBody::new(futures::stream::iter(frames))) } @@ -1034,10 +1085,95 @@ mod tests { use super::*; use bytes::Bytes; use http_body_util::Empty; + use openshell_core::GatewayInterceptorConfig; + use openshell_core::proto::CreateSandboxRequest; + use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, ProviderProfileSnapshot, + ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + }; + use prost::Message as _; + use std::convert::Infallible; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio_stream::wrappers::TcpListenerStream; use tower::Service; + #[derive(Clone)] + struct PostCommitTestInterceptor; + + #[tonic::async_trait] + impl GatewayInterceptor for PostCommitTestInterceptor { + async fn describe( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(InterceptorManifest { + name: "post-commit-test".to_string(), + failure_policy: "fail_open".to_string(), + bindings: vec![InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_open".to_string(), + }], + provider_profiles: false, + })) + } + + async fn evaluate( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(InterceptorResult { + allowed: true, + ..InterceptorResult::default() + })) + } + + async fn snapshot_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not a profile source")) + } + } + + fn grpc_frame(message: &[u8]) -> Bytes { + let mut frame = Vec::with_capacity(5 + message.len()); + frame.push(0); + frame.extend_from_slice(&u32::try_from(message.len()).unwrap().to_be_bytes()); + frame.extend_from_slice(message); + Bytes::from(frame) + } + + async fn post_commit_test_runtime() -> (GatewayInterceptorRuntime, tokio::task::JoinHandle<()>) + { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(PostCommitTestInterceptor)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "post-commit-test".to_string(), + grpc_endpoint: format!("http://{address}"), + ..GatewayInterceptorConfig::default() + }]) + .await + .unwrap() + .unwrap(); + (runtime, task) + } + #[test] fn uuid_request_id_generates_valid_uuid() { let mut maker = UuidRequestId; @@ -1134,8 +1270,8 @@ mod tests { Some(trailers.clone()), )); - let (response, observed, observed_trailers) = - collect_intercepted_grpc_response(response).await.unwrap(); + let (response, observation) = observe_intercepted_grpc_response(response).await; + let (observed, observed_trailers) = observation.unwrap(); assert_eq!(observed, bytes); assert_eq!(observed_trailers.as_ref(), Some(&trailers)); @@ -1149,6 +1285,63 @@ mod tests { assert_eq!(collected.to_bytes(), bytes); } + #[tokio::test] + async fn intercepted_grpc_response_preserves_body_error() { + let bytes = Bytes::from_static(b"committed-response-prefix"); + let response = Response::new(tonic_body_from_frames(vec![ + Ok(http_body::Frame::data(bytes.clone())), + Err(tonic::Status::unavailable("response stream failed")), + ])); + + let (response, observation) = observe_intercepted_grpc_response(response).await; + + let observation_error = observation.unwrap_err(); + assert!(observation_error.contains("failed to read gRPC response")); + assert!(observation_error.contains("response stream failed")); + let mut body = response.into_body(); + let data = body.frame().await.unwrap().unwrap(); + assert_eq!(data.data_ref(), Some(&bytes)); + let status = body.frame().await.unwrap().unwrap_err(); + assert_eq!(status.code(), tonic::Code::Unavailable); + assert_eq!(status.message(), "response stream failed"); + assert!(body.frame().await.is_none()); + } + + #[tokio::test] + async fn post_commit_decode_failure_preserves_committed_response() { + let (runtime, interceptor_task) = post_commit_test_runtime().await; + let committed = Arc::new(AtomicUsize::new(0)); + let committed_for_service = committed.clone(); + let committed_body = Bytes::from_static(b"malformed committed gRPC response"); + let committed_body_for_service = committed_body.clone(); + let inner = tower::service_fn(move |_request: Request| { + let committed = committed_for_service.clone(); + let body = committed_body_for_service.clone(); + async move { + committed.fetch_add(1, Ordering::SeqCst); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("0")); + Ok::<_, Infallible>(Response::new(tonic_body_from_bytes_and_trailers( + body, + Some(trailers), + ))) + } + }); + let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime)); + let request_body = grpc_frame(&CreateSandboxRequest::default().encode_to_vec()); + let request = Request::builder() + .uri("/openshell.v1.OpenShell/CreateSandbox") + .body(boxed_body_from_bytes(request_body)) + .unwrap(); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + + assert_eq!(committed.load(Ordering::SeqCst), 1); + let collected = response.into_body().collect().await.unwrap(); + assert_eq!(collected.to_bytes(), committed_body); + interceptor_task.abort(); + } + #[test] fn grpc_trailer_status_takes_precedence_over_headers() { let response = Response::builder() diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ea5f6662c3..af4b6a5cc9 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -168,6 +168,8 @@ 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. +Bindings that include `post_commit` must resolve to `failure_policy = "fail_open"`; the gateway rejects fail-closed post-commit configuration at startup. Post-commit evaluation is observational. If response observation or interceptor evaluation fails after a handler succeeds, the gateway logs and counts the failure but returns the committed response unchanged. + `provider_profile_sources` selects the exact ordered provider-profile source set. Omit it to use the built-in and user-managed sources. Use `{ type = "builtin" }`, `{ type = "user" }`, and `{ type = "interceptor", name = "" }` entries to compose a catalog. An interceptor entry must reference a configured service whose manifest advertises `provider_profiles = true`. Selecting only an interceptor makes that catalog authoritative; include local sources explicitly to compose them. Empty or duplicate source lists, unknown interceptor names, and duplicate normalized profile IDs fail closed. Source order controls collection and diagnostics, not override precedence. For an authoritative interceptor catalog, select only that interceptor: diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index ab80124e8a..f6b4a19210 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -118,6 +118,8 @@ message InterceptorBinding { repeated GatewayInterceptorPhase phases = 3; // Optional binding-specific failure policy. // Supported values are "fail_closed" and "fail_open". + // Bindings that include post_commit must resolve to "fail_open" because the + // gateway operation has already committed before that phase runs. string failure_policy = 4; } From cd40baa87af82ee7bbf0999a1399a9f50363f31f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 12 Jul 2026 21:22:37 -0700 Subject: [PATCH 17/27] fix(gateway-interceptors): reject ambiguous protobuf oneofs Signed-off-by: Drew Newberry --- architecture/gateway.md | 6 + .../openshell-gateway-interceptors/src/lib.rs | 399 +++++++++++++++++- 2 files changed, 403 insertions(+), 2 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 46067476fc..e18ba13921 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,6 +52,12 @@ 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. +The descriptor codec preserves protobuf `oneof` metadata and rejects wire or +JSON input that selects multiple alternatives in one group. This interceptor +boundary is intentionally stricter than protobuf's last-member-wins behavior: +ambiguous requests fail with `INVALID_ARGUMENT` before interceptor evaluation +or handler dispatch. + Each interceptor evaluation selects exactly one phase payload: `modify_operation`, `validate`, or `post_commit`. Modification and validation payloads carry the protobuf JSON operation entering that phase. Post-commit diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 95a326e29d..c3bd55b7cd 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -67,6 +67,12 @@ pub enum InterceptorError { pub type Result = std::result::Result; +/// Descriptor-backed protobuf/JSON codec used at the interceptor boundary. +/// +/// The codec rejects wire or JSON inputs that select multiple alternatives in +/// the same protobuf `oneof`. This is stricter than protobuf's usual +/// last-member-wins behavior so an interceptor never authorizes an ambiguous +/// representation. #[derive(Debug, Clone)] pub struct ProtoJsonCodec { descriptors: Arc, @@ -1184,10 +1190,42 @@ impl ProtoDescriptors { .options .as_ref() .is_some_and(prost_types::MessageOptions::map_entry); + let mut oneofs = message + .oneof_decl + .iter() + .enumerate() + .map(|(index, oneof)| { + let name = oneof.name.clone().unwrap_or_default(); + if name.is_empty() { + return Err(InterceptorError::Config(format!( + "oneof {index} on message '{full_name}' has no name" + ))); + } + Ok(OneofDesc { + name, + field_numbers: BTreeSet::new(), + }) + }) + .collect::>>()?; let mut fields = BTreeMap::new(); let mut fields_by_json = HashMap::new(); for field in &message.field { let field_desc = FieldDesc::from_proto(field)?; + if let Some(oneof_index) = field_desc.oneof_index { + let oneof = oneofs.get_mut(oneof_index as usize).ok_or_else(|| { + InterceptorError::Config(format!( + "field '{}' on message '{full_name}' references invalid oneof index {oneof_index}", + field_desc.name + )) + })?; + if field_desc.repeated { + return Err(InterceptorError::Config(format!( + "repeated field '{}' on message '{full_name}' cannot belong to oneof '{}'", + field_desc.name, oneof.name + ))); + } + oneof.field_numbers.insert(field_desc.number); + } 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); @@ -1197,6 +1235,7 @@ impl ProtoDescriptors { MessageDesc { fields, fields_by_json, + oneofs, map_entry, }, ); @@ -1249,11 +1288,13 @@ 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) { + self.validate_message_oneofs(type_name, bytes)?; return value; } let message = self.message(type_name)?; let mut values: HashMap> = HashMap::new(); + let mut selected_oneofs = HashMap::new(); let mut input = bytes; while !input.is_empty() { let key = decode_varint(&mut input)?; @@ -1265,6 +1306,7 @@ impl ProtoDescriptors { skip_unknown(wire_type, &mut input)?; continue; }; + message.select_oneof(type_name, field, &mut selected_oneofs)?; let decoded = self.decode_field_value(field, wire_type, &mut input)?; values.entry(field_number).or_default().extend(decoded); } @@ -1287,6 +1329,37 @@ impl ProtoDescriptors { Ok(Value::Object(out)) } + fn validate_message_oneofs(&self, type_name: &str, bytes: &[u8]) -> Result<()> { + let message = self.message(type_name)?; + let mut selected_oneofs = 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; + }; + message.select_oneof(type_name, field, &mut selected_oneofs)?; + if field.kind == FieldKind::Message && wire_type == 2 { + let bytes = decode_length_delimited(&mut input)?; + let nested_type = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "message field {} lacks type_name", + field.name + )) + })?; + self.validate_message_oneofs(nested_type, &bytes)?; + } else { + skip_unknown(wire_type, &mut input)?; + } + } + Ok(()) + } + fn decode_field_value( &self, field: &FieldDesc, @@ -1464,7 +1537,8 @@ impl ProtoDescriptors { "{type_name} JSON must be an object" ))); }; - let mut out = Vec::new(); + let mut fields = Vec::with_capacity(map.len()); + let mut selected_oneofs = HashMap::new(); for (json_name, value) in map { if value.is_null() { continue; @@ -1475,6 +1549,12 @@ impl ProtoDescriptors { ))); }; let field = message.fields.get(number).expect("field index is valid"); + message.select_oneof(type_name, field, &mut selected_oneofs)?; + fields.push((field, value)); + } + + let mut out = Vec::new(); + for (field, value) in fields { if self.field_is_map(field) { self.encode_map_field(field, value, &mut out)?; } else if field.repeated { @@ -1648,9 +1728,47 @@ impl ProtoDescriptors { struct MessageDesc { fields: BTreeMap, fields_by_json: HashMap, + oneofs: Vec, map_entry: bool, } +impl MessageDesc { + fn select_oneof( + &self, + type_name: &str, + field: &FieldDesc, + selected: &mut HashMap, + ) -> Result<()> { + let Some(oneof_index) = field.oneof_index else { + return Ok(()); + }; + let oneof = self + .oneofs + .get(oneof_index as usize) + .expect("oneof indexes are validated when descriptors are loaded"); + debug_assert!(oneof.field_numbers.contains(&field.number)); + if let Some(previous_number) = selected.insert(oneof_index, field.number) + && previous_number != field.number + { + let previous = self + .fields + .get(&previous_number) + .expect("oneof member field is indexed"); + return Err(InterceptorError::Transcode(format!( + "message '{type_name}' sets multiple fields in oneof '{}': '{}' and '{}'", + oneof.name, previous.name, field.name + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct OneofDesc { + name: String, + field_numbers: BTreeSet, +} + #[derive(Debug, Clone)] struct FieldDesc { name: String, @@ -1659,6 +1777,7 @@ struct FieldDesc { repeated: bool, kind: FieldKind, type_name: Option, + oneof_index: Option, } impl FieldDesc { @@ -1677,6 +1796,16 @@ impl FieldDesc { .type_name .as_ref() .map(|name| trim_type_name(name).to_string()); + let oneof_index = field + .oneof_index + .map(|index| { + u32::try_from(index).map_err(|_| { + InterceptorError::Config(format!( + "field '{name}' has invalid oneof index {index}" + )) + }) + }) + .transpose()?; Ok(Self { name, json_name, @@ -1684,6 +1813,7 @@ impl FieldDesc { repeated, kind, type_name, + oneof_index, }) } @@ -2126,7 +2256,8 @@ fn json_u32(value: &Value, field: &str) -> Result { mod tests { use super::*; use openshell_core::proto::{ - CreateSandboxRequest, SandboxSpec, SandboxTemplate, UpdateConfigRequest, + CreateSandboxRequest, GpuResourceRequirements, SandboxSpec, SandboxTemplate, + UpdateConfigRequest, }; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -2404,6 +2535,270 @@ mod tests { assert_eq!(decoded, request); } + #[test] + fn descriptor_loading_rejects_invalid_oneof_indexes() { + let descriptor_set = FileDescriptorSet { + file: vec![FileDescriptorProto { + package: Some("test".to_string()), + message_type: vec![DescriptorProto { + name: Some("Broken".to_string()), + field: vec![FieldDescriptorProto { + name: Some("value".to_string()), + number: Some(1), + label: Some(Label::Optional as i32), + r#type: Some(Type::String as i32), + oneof_index: Some(0), + ..FieldDescriptorProto::default() + }], + ..DescriptorProto::default() + }], + ..FileDescriptorProto::default() + }], + }; + + let err = ProtoDescriptors::from_descriptor_set(&descriptor_set.encode_to_vec()) + .expect_err("out-of-range oneof index must be rejected"); + + assert!(err.to_string().contains("test.Broken")); + assert!(err.to_string().contains("invalid oneof index 0")); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_policy_merge_oneof_wire_input() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let add_then_remove = vec![0x0a, 0x00, 0x1a, 0x00]; + let remove_then_add = vec![0x1a, 0x00, 0x0a, 0x00]; + + for bytes in [add_then_remove, remove_then_add] { + let err = descriptors + .decode_message_to_json("openshell.v1.PolicyMergeOperation", &bytes) + .expect_err("multiple operation alternatives must be rejected"); + let message = err.to_string(); + assert!(message.contains("openshell.v1.PolicyMergeOperation")); + assert!(message.contains("oneof 'operation'")); + assert!(message.contains("add_rule")); + assert!(message.contains("remove_rule")); + } + } + + #[test] + fn dynamic_codec_rejects_ambiguous_interceptor_phase_wire_input() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let modify_then_validate = vec![0x32, 0x00, 0x3a, 0x00]; + + let err = descriptors + .decode_message_to_json( + "openshell.gateway_interceptor.v1.InterceptorEvaluation", + &modify_then_validate, + ) + .expect_err("multiple phase alternatives must be rejected"); + + let message = err.to_string(); + assert!(message.contains("oneof 'phase'")); + assert!(message.contains("modify_operation")); + assert!(message.contains("validate")); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_well_known_value_wire_input() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let mut string_then_bool = Vec::new(); + encode_key(3, 2, &mut string_then_bool); + encode_length_delimited(b"text", &mut string_then_bool).unwrap(); + encode_key(4, 0, &mut string_then_bool); + encode_varint(1, &mut string_then_bool); + + let err = descriptors + .decode_message_to_json("google.protobuf.Value", &string_then_bool) + .expect_err("well-known Value alternatives must be rejected"); + + let message = err.to_string(); + assert!(message.contains("google.protobuf.Value")); + assert!(message.contains("oneof 'kind'")); + assert!(message.contains("string_value")); + assert!(message.contains("bool_value")); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_value_nested_in_well_known_struct() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let mut ambiguous_value = Vec::new(); + encode_key(3, 2, &mut ambiguous_value); + encode_length_delimited(b"text", &mut ambiguous_value).unwrap(); + encode_key(4, 0, &mut ambiguous_value); + encode_varint(1, &mut ambiguous_value); + + let mut map_entry = Vec::new(); + encode_key(1, 2, &mut map_entry); + encode_length_delimited(b"key", &mut map_entry).unwrap(); + encode_key(2, 2, &mut map_entry); + encode_length_delimited(&ambiguous_value, &mut map_entry).unwrap(); + + let mut struct_bytes = Vec::new(); + encode_key(1, 2, &mut struct_bytes); + encode_length_delimited(&map_entry, &mut struct_bytes).unwrap(); + + let err = descriptors + .decode_message_to_json("google.protobuf.Struct", &struct_bytes) + .expect_err("nested well-known Value alternatives must be rejected"); + + assert!(err.to_string().contains("google.protobuf.Value")); + assert!(err.to_string().contains("oneof 'kind'")); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_oneof_json_before_encoding() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + let err = descriptors + .encode_json_to_message( + "openshell.v1.PolicyMergeOperation", + &json!({ + "addRule": {}, + "removeRule": {} + }), + ) + .expect_err("multiple JSON alternatives must be rejected"); + + assert!(err.to_string().contains("oneof 'operation'")); + } + + #[test] + fn dynamic_codec_round_trips_each_policy_merge_oneof_alternative() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for field in [ + "addRule", + "removeEndpoint", + "removeRule", + "addDenyRules", + "addAllowRules", + "removeBinary", + ] { + let json = json!({ (field): {} }); + let encoded = descriptors + .encode_json_to_message("openshell.v1.PolicyMergeOperation", &json) + .unwrap(); + let decoded = descriptors + .decode_message_to_json("openshell.v1.PolicyMergeOperation", &encoded) + .unwrap(); + let decoded = decoded + .as_object() + .expect("decoded oneof must be an object"); + assert_eq!(decoded.len(), 1); + assert!(decoded.contains_key(field)); + } + } + + #[test] + fn dynamic_codec_preserves_proto3_optional_presence() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for request in [ + GpuResourceRequirements { count: None }, + GpuResourceRequirements { count: Some(0) }, + GpuResourceRequirements { count: Some(2) }, + ] { + let json = descriptors + .decode_message_to_json( + "openshell.v1.GpuResourceRequirements", + &request.encode_to_vec(), + ) + .unwrap(); + let encoded = descriptors + .encode_json_to_message("openshell.v1.GpuResourceRequirements", &json) + .unwrap(); + let decoded = GpuResourceRequirements::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + } + + #[test] + fn dynamic_codec_rejects_nested_oneof_violations() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let ambiguous_merge = vec![0x0a, 0x00, 0x1a, 0x00]; + let mut update_config = Vec::new(); + encode_key(7, 2, &mut update_config); + encode_length_delimited(&ambiguous_merge, &mut update_config).unwrap(); + + let err = descriptors + .decode_message_to_json("openshell.v1.UpdateConfigRequest", &update_config) + .expect_err("nested oneof violation must be rejected"); + + assert!(err.to_string().contains("PolicyMergeOperation")); + assert!(err.to_string().contains("oneof 'operation'")); + } + + #[tokio::test] + async fn request_middleware_rejects_ambiguous_oneof_before_evaluation() { + let selector = RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "UpdateConfig".to_string(), + }; + let plan = BindingPlan { + interceptor_name: "must-not-run".to_string(), + binding_id: "must-not-run".to_string(), + selector: selector.clone(), + 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 runtime = GatewayInterceptorRuntime { + bindings: Arc::new(BTreeMap::from([( + (selector, Phase::ModifyOperation), + vec![plan], + )])), + profile_sources: Arc::new(BTreeMap::new()), + routes: Arc::new( + routes::OpenShellRouteIndex::from_descriptor_set( + openshell_core::FILE_DESCRIPTOR_SET, + ) + .unwrap(), + ), + descriptors: Arc::new( + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(), + ), + }; + let ambiguous_merge = vec![0x0a, 0x00, 0x1a, 0x00]; + let mut request = Vec::new(); + encode_key(7, 2, &mut request); + encode_length_delimited(&ambiguous_merge, &mut request).unwrap(); + let body = GrpcFrame { + compressed: false, + message: request, + } + .encode() + .unwrap(); + + let err = runtime + .evaluate_request( + "/openshell.v1.OpenShell/UpdateConfig", + &body, + &EvaluationContext { + principal: BTreeMap::new(), + validate_current_state: None, + }, + ) + .await + .expect_err("ambiguous request must fail before interceptor evaluation"); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("oneof 'operation'")); + } + #[test] fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { let descriptors = From 7b3c41576f43a92563fe9628b2d8702faa95d469 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 12 Jul 2026 21:49:08 -0700 Subject: [PATCH 18/27] fix(gateway-interceptors): validate patch candidates per binding Signed-off-by: Drew Newberry --- architecture/gateway.md | 7 + .../openshell-gateway-interceptors/src/lib.rs | 525 ++++++++++++++++-- 2 files changed, 492 insertions(+), 40 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index e18ba13921..fee1bf23d1 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -58,6 +58,13 @@ boundary is intentionally stricter than protobuf's last-member-wins behavior: ambiguous requests fail with `INVALID_ARGUMENT` before interceptor evaluation or handler dispatch. +Modification results are atomic per binding. After applying one binding's full +JSON Patch list, the middleware re-encodes the candidate as the request's +protobuf type before accepting it. Invalid candidates follow that binding's +failure policy: fail-open restores the exact pre-binding operation, while +fail-closed rejects the request before handler dispatch. Later bindings only +observe schema-valid operations. + Each interceptor evaluation selects exactly one phase payload: `modify_operation`, `validate`, or `post_commit`. Modification and validation payloads carry the protobuf JSON operation entering that phase. Post-commit diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index c3bd55b7cd..b3ce221cda 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -246,6 +246,36 @@ pub struct InterceptedRequest { selector: RpcSelector, } +#[derive(Debug, Clone, PartialEq)] +struct ValidatedOperation { + json: Value, + encoded: Vec, +} + +impl ValidatedOperation { + fn new(descriptors: &ProtoDescriptors, type_name: &str, json: Value) -> Result { + let encoded = descriptors.encode_json_to_message(type_name, &json)?; + Ok(Self { json, encoded }) + } + + fn apply_patches( + &self, + descriptors: &ProtoDescriptors, + type_name: &str, + patches: &[JsonPatch], + ) -> Result { + let json = apply_json_patches(&self.json, patches)?; + let encoded = descriptors + .encode_json_to_message(type_name, &json) + .map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + })?; + Ok(Self { json, encoded }) + } +} + #[derive(Debug, Clone)] pub struct ProviderProfileSourceSnapshot { pub revision: String, @@ -441,25 +471,40 @@ impl GatewayInterceptorRuntime { .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? .to_string(); let frame = GrpcFrame::decode(body)?; - let mut operation = self + let operation = self .descriptors .decode_message_to_json(&input_type, &frame.message) .map_err(|err| Status::invalid_argument(err.to_string()))?; + let mut operation = ValidatedOperation::new(&self.descriptors, &input_type, operation) + .map_err(|err| Status::invalid_argument(err.to_string()))?; operation = self - .evaluate_phase(&selector, Phase::ModifyOperation, operation, context) + .evaluate_phase( + &selector, + Phase::ModifyOperation, + &input_type, + operation, + context, + ) .await?; operation = self - .evaluate_phase(&selector, Phase::Validate, operation, context) + .evaluate_phase(&selector, Phase::Validate, &input_type, operation, context) .await?; - let message = self + let final_encoding = self .descriptors - .encode_json_to_message(&input_type, &operation) - .map_err(|err| Status::invalid_argument(err.to_string()))?; + .encode_json_to_message(&input_type, &operation.json) + .map_err(|err| { + Status::internal(format!("validated operation became invalid: {err}")) + })?; + if final_encoding != operation.encoded { + return Err(Status::internal( + "validated operation encoding changed before dispatch", + )); + } let body = GrpcFrame { compressed: false, - message, + message: operation.encoded, } .encode() .map_err(|err| Status::invalid_argument(err.to_string()))?; @@ -482,9 +527,13 @@ impl GatewayInterceptorRuntime { .descriptors .decode_message_to_json(output_type, &frame.message) .map_err(|err| Status::invalid_argument(err.to_string()))?; + let committed_response = + ValidatedOperation::new(&self.descriptors, output_type, committed_response) + .map_err(|err| Status::invalid_argument(err.to_string()))?; self.evaluate_phase( &intercepted.selector, Phase::PostCommit, + output_type, committed_response, context, ) @@ -502,16 +551,17 @@ impl GatewayInterceptorRuntime { &self, selector: &RpcSelector, phase: Phase, - operation: Value, + operation_type: &str, + operation: ValidatedOperation, context: &EvaluationContext, - ) -> std::result::Result { + ) -> 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 = evaluate_plan(plan, operation.json.clone(), context).await; let result = match result { Ok(result) => result, Err(err) => { @@ -519,40 +569,57 @@ impl GatewayInterceptorRuntime { continue; } }; + operation = apply_evaluation_result( + &self.descriptors, + operation_type, + plan, + &result, + operation, + )?; + } + Ok(operation) + } +} - if let Err(err) = validate_result_contract(plan, &result) { - apply_failure_policy(plan, &err)?; - continue; - } +fn apply_evaluation_result( + descriptors: &ProtoDescriptors, + operation_type: &str, + plan: &BindingPlan, + result: &InterceptorResult, + operation: ValidatedOperation, +) -> std::result::Result { + if let Err(err) = validate_result_contract(plan, result) { + apply_failure_policy(plan, &err)?; + return Ok(operation); + } - 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); - emit_evaluation_log(plan, &result, "deny", 0); - return Err(status_from_result(&result, reason)); + 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); + emit_evaluation_log(plan, result, "deny", 0); + return Err(status_from_result(result, reason)); + } + + if plan.phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + match operation.apply_patches(descriptors, operation_type, &result.patches) { + Ok(candidate) => { + emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, result, "allow", patch_count); + Ok(candidate) } - - if phase == Phase::ModifyOperation && !result.patches.is_empty() { - let patch_count = result.patches.len(); - match apply_json_patches(&operation, &result.patches) { - 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)?; - } - } - } else { - emit_evaluation_metrics(plan, "allow", 0); - emit_evaluation_log(plan, &result, "allow", 0); + Err(err) => { + apply_failure_policy(plan, &err)?; + Ok(operation) } } + } else { + emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, result, "allow", 0); Ok(operation) } } @@ -2260,7 +2327,10 @@ mod tests { UpdateConfigRequest, }; use serde_json::json; - use std::sync::{Arc, Mutex}; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }; use tracing_subscriber::layer::SubscriberExt; #[derive(Clone)] @@ -2277,6 +2347,126 @@ mod tests { } } + #[derive(Debug)] + struct AtomicCounter(Arc); + + impl metrics::CounterFn for AtomicCounter { + fn increment(&self, value: u64) { + self.0.fetch_add(value, Ordering::Relaxed); + } + + fn absolute(&self, value: u64) { + self.0.fetch_max(value, Ordering::Relaxed); + } + } + + #[derive(Debug, Default)] + struct TestRecorder { + evaluations: Arc, + patches: Arc, + fail_open: Arc, + fail_closed: Arc, + } + + impl metrics::Recorder for TestRecorder { + fn describe_counter( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_gauge( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_histogram( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn register_counter( + &self, + key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Counter { + let counter = match key.name() { + "openshell_gateway_interceptor_evaluations_total" => &self.evaluations, + "openshell_gateway_interceptor_patches_total" => &self.patches, + "openshell_gateway_interceptor_fail_open_total" => &self.fail_open, + "openshell_gateway_interceptor_fail_closed_total" => &self.fail_closed, + _ => return metrics::Counter::noop(), + }; + metrics::Counter::from_arc(Arc::new(AtomicCounter(counter.clone()))) + } + + fn register_gauge( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Gauge { + metrics::Gauge::noop() + } + + fn register_histogram( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + metrics::Histogram::noop() + } + } + + impl TestRecorder { + fn count(counter: &AtomicU64) -> u64 { + counter.load(Ordering::Relaxed) + } + } + + fn test_modify_plan(failure_policy: FailurePolicy) -> BindingPlan { + BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "UpdateConfig".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy, + 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(), + ), + } + } + + fn patch(op: &str, path: &str, value: Value) -> JsonPatch { + JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_protobuf_value(value).unwrap()), + from: String::new(), + } + } + + fn allowed_result(patches: Vec) -> InterceptorResult { + InterceptorResult { + allowed: true, + patches, + ..InterceptorResult::default() + } + } + #[test] fn parses_timeout_suffixes() { assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); @@ -2891,4 +3081,259 @@ mod tests { apply_failure_policy(&plan, &err).unwrap(); assert_eq!(operation, json!({ "name": "demo" })); } + + #[test] + fn validated_operation_rejects_message_repeated_and_enum_type_errors() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let cases = [ + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {}}), + patch("replace", "/spec", json!("not-a-message")), + ), + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {"providers": []}}), + patch("replace", "/spec/providers", json!({"provider": "github"})), + ), + ( + "openshell.v1.ReportPolicyStatusRequest", + json!({ + "sandboxId": "sandbox-id", + "version": 1, + "status": "POLICY_STATUS_LOADED" + }), + patch("replace", "/status", json!("POLICY_STATUS_UNKNOWN")), + ), + ]; + + for (type_name, json, invalid_patch) in cases { + let operation = ValidatedOperation::new(&descriptors, type_name, json).unwrap(); + let err = operation + .apply_patches(&descriptors, type_name, &[invalid_patch]) + .expect_err("wrong-type patch must not produce a validated operation"); + + assert!(matches!(err, InterceptorError::InvalidResult(_))); + assert!(err.to_string().contains("patched operation is not valid")); + } + } + + #[tokio::test] + async fn wrong_type_patch_fails_open_to_exact_prior_operation_and_metrics() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let outcome = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(TestRecorder::count(&recorder.fail_open), 1); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn wrong_type_patch_fails_closed_before_dispatch_and_metrics() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailClosed); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let status = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .expect_err("fail-closed invalid candidate must stop dispatch"); + + assert_eq!(status.code(), Code::PermissionDenied); + assert!(status.message().contains("patched operation is not valid")); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 1); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn invalid_patch_list_is_atomic() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let result = allowed_result(vec![ + patch("replace", "/name", json!("partially-mutated")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + + let outcome = apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation, + ) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(outcome.json["name"], "demo"); + } + + #[tokio::test] + async fn later_binding_sees_pre_binding_value_after_invalid_candidate_fails_open() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let invalid_first = allowed_result(vec![ + patch("replace", "/name", json!("rejected-candidate")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + let second = allowed_result(vec![ + patch("test", "/name", json!("demo")), + patch("replace", "/name", json!("accepted-candidate")), + ]); + + let operation = apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &plan, + &invalid_first, + operation, + ) + .unwrap(); + let operation = apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &plan, + &second, + operation, + ) + .unwrap(); + + assert_eq!(operation.json["name"], "accepted-candidate"); + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted-candidate"); + assert_eq!(decoded.expected_resource_version, 7); + } + + #[tokio::test] + async fn ambiguous_oneof_patch_obeys_binding_failure_policy() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.PolicyMergeOperation", + json!({"addRule": {}}), + ) + .unwrap(); + let result = allowed_result(vec![patch("add", "/removeRule", json!({}))]); + + let fail_open = apply_evaluation_result( + &descriptors, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation.clone(), + ) + .unwrap(); + let fail_closed = apply_evaluation_result( + &descriptors, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation.clone(), + ) + .expect_err("ambiguous oneof candidate must fail closed"); + + assert_eq!(fail_open, operation); + assert_eq!(fail_closed.code(), Code::PermissionDenied); + assert!(fail_closed.message().contains("oneof 'operation'")); + } + + #[tokio::test] + async fn accepted_candidate_updates_encoded_operation_and_patch_metrics() { + tokio::task::yield_now().await; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &descriptors, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let result = allowed_result(vec![patch("replace", "/name", json!("accepted"))]); + let recorder = TestRecorder::default(); + + let operation = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &descriptors, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + }) + .unwrap(); + + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted"); + assert_eq!(TestRecorder::count(&recorder.evaluations), 1); + assert_eq!(TestRecorder::count(&recorder.patches), 1); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + } } From aff12b13a84b8e43d4b0b0b9094f21f3fedcf842 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 12 Jul 2026 23:29:47 -0700 Subject: [PATCH 19/27] refactor(gateway-interceptors): use reflected protobuf codec Signed-off-by: Drew Newberry --- Cargo.lock | 15 +- Cargo.toml | 1 + architecture/gateway.md | 11 +- .../openshell-gateway-interceptors/Cargo.toml | 2 +- .../openshell-gateway-interceptors/src/lib.rs | 3307 +---------------- .../src/plan.rs | 737 ++++ .../src/profile_source.rs | 104 + .../src/proto_json.rs | 341 ++ .../src/routes.rs | 56 +- .../src/runtime.rs | 1199 ++++++ examples/governance-interceptor/Cargo.lock | 34 +- 11 files changed, 2465 insertions(+), 3342 deletions(-) create mode 100644 crates/openshell-gateway-interceptors/src/plan.rs create mode 100644 crates/openshell-gateway-interceptors/src/profile_source.rs create mode 100644 crates/openshell-gateway-interceptors/src/proto_json.rs create mode 100644 crates/openshell-gateway-interceptors/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 2463a35213..08d9f9bb8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3757,12 +3757,12 @@ dependencies = [ name = "openshell-gateway-interceptors" version = "0.0.0" dependencies = [ - "base64 0.22.1", "hyper-util", "json-patch", "metrics", "openshell-core", "prost", + "prost-reflect", "prost-types", "serde_json", "sha2 0.10.9", @@ -4588,6 +4588,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "base64 0.22.1", + "prost", + "prost-types", + "serde", + "serde-value", +] + [[package]] name = "prost-types" version = "0.14.3" diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8c..184c9aa398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tonic-prost = "0.14" tonic-prost-build = "0.14" prost = "0.14" prost-types = "0.14" +prost-reflect = { version = "0.16.5", features = ["serde"] } # HTTP server axum = { version = "0.8", features = ["ws"] } diff --git a/architecture/gateway.md b/architecture/gateway.md index fee1bf23d1..581ee83e6b 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,11 +52,12 @@ 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. -The descriptor codec preserves protobuf `oneof` metadata and rejects wire or -JSON input that selects multiple alternatives in one group. This interceptor -boundary is intentionally stricter than protobuf's last-member-wins behavior: -ambiguous requests fail with `INVALID_ARGUMENT` before interceptor evaluation -or handler dispatch. +The descriptor codec uses protobuf's standard `oneof` semantics. If binary +input contains multiple alternatives from one group, the last member on the +wire wins. The middleware converts that selected value to ProtoJSON and +re-encodes it before dispatch, so the interceptor and handler observe the same +canonical request. ProtoJSON input that names multiple alternatives remains +invalid. Modification results are atomic per binding. After applying one binding's full JSON Patch list, the middleware re-encodes the candidate as the request's diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index 24e140a6c6..7bcdd6eebf 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -13,11 +13,11 @@ 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-reflect = { workspace = true } prost-types = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index b3ce221cda..32b4f3e525 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -11,47 +11,18 @@ #![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::ProviderProfile; -use openshell_core::proto::gateway_interceptor::v1::{ - DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, - InterceptorResult, InterceptorSelector, JsonPatch, ModifyOperationEvaluation, - PostCommitEvaluation, ProviderProfileSnapshotRequest, ValidateEvaluation, - gateway_interceptor_client::GatewayInterceptorClient, interceptor_evaluation, -}; -use prost::Message; -use prost_types::{ - DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, - FileDescriptorSet, Struct, - field_descriptor_proto::{Label, Type}, -}; -use serde_json::{Map, Number, Value}; -use sha2::Digest as _; -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::{info, warn}; +use openshell_core::config::GatewayInterceptorConfig; +pub(crate) mod plan; +pub(crate) mod profile_source; +pub(crate) mod proto_json; pub mod routes; +pub(crate) mod runtime; -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; +pub use plan::{FailurePolicy, Phase, RpcSelector, parse_duration}; +pub use profile_source::{GatewayInterceptorProfileSource, ProviderProfileSourceSnapshot}; +pub use proto_json::ProtoJsonCodec; +pub use runtime::{EvaluationContext, GatewayInterceptorRuntime, InterceptedRequest}; #[derive(Debug, thiserror::Error)] pub enum InterceptorError { @@ -67,239 +38,6 @@ pub enum InterceptorError { pub type Result = std::result::Result; -/// Descriptor-backed protobuf/JSON codec used at the interceptor boundary. -/// -/// The codec rejects wire or JSON inputs that select multiple alternatives in -/// the same protobuf `oneof`. This is stricter than protobuf's usual -/// last-member-wins behavior so an interceptor never authorizes an ambiguous -/// representation. -#[derive(Debug, Clone)] -pub struct ProtoJsonCodec { - descriptors: Arc, -} - -impl ProtoJsonCodec { - pub fn from_descriptor_set(bytes: &[u8]) -> Result { - Ok(Self { - descriptors: Arc::new(ProtoDescriptors::from_descriptor_set(bytes)?), - }) - } - - pub fn openshell() -> Result { - Self::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) - } - - pub fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result - where - M: Message, - { - self.descriptors - .decode_message_to_json(type_name, &message.encode_to_vec()) - } - - pub fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { - self.descriptors.encode_json_to_message(type_name, value) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Phase { - ModifyOperation, - Validate, - PostCommit, -} - -impl Phase { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::ModifyOperation => "modify_operation", - Self::Validate => "validate", - Self::PostCommit => "post_commit", - } - } -} - -impl TryFrom for Phase { - type Error = InterceptorError; - - fn try_from(value: GatewayInterceptorPhase) -> Result { - match value { - 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::ModifyOperation => Self::ModifyOperation, - GatewayInterceptorPhaseConfig::Validate => Self::Validate, - GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FailurePolicy { - FailClosed, - FailOpen, -} - -impl FailurePolicy { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::FailClosed => "fail_closed", - Self::FailOpen => "fail_open", - } - } -} - -impl From for FailurePolicy { - fn from(value: GatewayInterceptorFailurePolicy) -> Self { - match value { - GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, - GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, - } - } -} - -#[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>>, - profile_sources: Arc>, - routes: Arc, - descriptors: Arc, -} - -#[derive(Debug, Clone)] -pub struct EvaluationContext { - pub principal: BTreeMap, - pub validate_current_state: Option, -} - -#[derive(Debug, Clone)] -pub struct InterceptedRequest { - pub body: Vec, - selector: RpcSelector, -} - -#[derive(Debug, Clone, PartialEq)] -struct ValidatedOperation { - json: Value, - encoded: Vec, -} - -impl ValidatedOperation { - fn new(descriptors: &ProtoDescriptors, type_name: &str, json: Value) -> Result { - let encoded = descriptors.encode_json_to_message(type_name, &json)?; - Ok(Self { json, encoded }) - } - - fn apply_patches( - &self, - descriptors: &ProtoDescriptors, - type_name: &str, - patches: &[JsonPatch], - ) -> Result { - let json = apply_json_patches(&self.json, patches)?; - let encoded = descriptors - .encode_json_to_message(type_name, &json) - .map_err(|err| { - InterceptorError::InvalidResult(format!( - "patched operation is not valid {type_name}: {err}" - )) - })?; - Ok(Self { json, encoded }) - } -} - -#[derive(Debug, Clone)] -pub struct ProviderProfileSourceSnapshot { - pub revision: String, - pub profiles: Vec, -} - -#[derive(Clone)] -pub struct GatewayInterceptorProfileSource { - interceptor_name: String, - source_id: String, - timeout: Duration, - client: GatewayInterceptorClient, -} - -impl std::fmt::Debug for GatewayInterceptorProfileSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GatewayInterceptorProfileSource") - .field("interceptor_name", &self.interceptor_name) - .field("source_id", &self.source_id) - .field("timeout", &self.timeout) - .finish_non_exhaustive() - } -} - /// Return `None` when no interceptors are configured. pub async fn initialize( configs: Vec, @@ -310,3030 +48,3 @@ pub async fn initialize( let runtime = GatewayInterceptorRuntime::build(configs).await?; Ok(Some(runtime)) } - -impl GatewayInterceptorRuntime { - async fn build(mut configs: Vec) -> Result { - validate_interceptor_configs(&configs)?; - 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(); - let mut profile_sources = BTreeMap::new(); - - for config in configs { - 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 = - 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 manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; - let service_default = config - .failure_policy - .map(FailurePolicy::from) - .or(manifest_default) - .unwrap_or(FailurePolicy::FailClosed); - 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 { - 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); - } - } - - if manifest.provider_profiles { - let source_id = format!("interceptor/{}", config.name); - profile_sources.insert( - config.name.clone(), - GatewayInterceptorProfileSource { - interceptor_name: config.name.clone(), - source_id, - timeout, - client: GatewayInterceptorClient::new(channel.clone()) - .max_decoding_message_size(max_response_bytes), - }, - ); - } - } - - let count: usize = bindings.values().map(Vec::len).sum(); - info!( - bindings = count, - profile_sources = profile_sources.len(), - "gateway interceptors initialized" - ); - Ok(Self { - bindings: Arc::new(bindings), - profile_sources: Arc::new(profile_sources), - routes: Arc::new(routes), - descriptors: Arc::new(descriptors), - }) - } - - #[must_use] - pub fn provider_profile_source( - &self, - interceptor_name: &str, - ) -> Option { - self.profile_sources.get(interceptor_name).cloned() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.bindings.is_empty() && self.profile_sources.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::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 operation = self - .descriptors - .decode_message_to_json(&input_type, &frame.message) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - let mut operation = ValidatedOperation::new(&self.descriptors, &input_type, operation) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - - operation = self - .evaluate_phase( - &selector, - Phase::ModifyOperation, - &input_type, - operation, - context, - ) - .await?; - operation = self - .evaluate_phase(&selector, Phase::Validate, &input_type, operation, context) - .await?; - - let final_encoding = self - .descriptors - .encode_json_to_message(&input_type, &operation.json) - .map_err(|err| { - Status::internal(format!("validated operation became invalid: {err}")) - })?; - if final_encoding != operation.encoded { - return Err(Status::internal( - "validated operation encoding changed before dispatch", - )); - } - let body = GrpcFrame { - compressed: false, - message: operation.encoded, - } - .encode() - .map_err(|err| Status::invalid_argument(err.to_string()))?; - - Ok(InterceptedRequest { body, selector }) - } - - pub async fn evaluate_post_commit( - &self, - intercepted: &InterceptedRequest, - response_body: &[u8], - context: &EvaluationContext, - ) -> std::result::Result<(), Status> { - let output_type = self - .routes - .output_type(&intercepted.selector.service, &intercepted.selector.method) - .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))?; - let frame = GrpcFrame::decode(response_body)?; - let committed_response = self - .descriptors - .decode_message_to_json(output_type, &frame.message) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - let committed_response = - ValidatedOperation::new(&self.descriptors, output_type, committed_response) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - self.evaluate_phase( - &intercepted.selector, - Phase::PostCommit, - output_type, - committed_response, - context, - ) - .await - .map(|_| ()) - } - - #[must_use] - pub fn has_post_commit(&self, intercepted: &InterceptedRequest) -> bool { - self.bindings - .contains_key(&(intercepted.selector.clone(), Phase::PostCommit)) - } - - async fn evaluate_phase( - &self, - selector: &RpcSelector, - phase: Phase, - operation_type: &str, - operation: ValidatedOperation, - 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.json.clone(), context).await; - let result = match result { - Ok(result) => result, - Err(err) => { - apply_failure_policy(plan, &err)?; - continue; - } - }; - operation = apply_evaluation_result( - &self.descriptors, - operation_type, - plan, - &result, - operation, - )?; - } - Ok(operation) - } -} - -fn apply_evaluation_result( - descriptors: &ProtoDescriptors, - operation_type: &str, - plan: &BindingPlan, - result: &InterceptorResult, - operation: ValidatedOperation, -) -> std::result::Result { - if let Err(err) = validate_result_contract(plan, result) { - apply_failure_policy(plan, &err)?; - return Ok(operation); - } - - 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); - emit_evaluation_log(plan, result, "deny", 0); - return Err(status_from_result(result, reason)); - } - - if plan.phase == Phase::ModifyOperation && !result.patches.is_empty() { - let patch_count = result.patches.len(); - match operation.apply_patches(descriptors, operation_type, &result.patches) { - Ok(candidate) => { - emit_evaluation_metrics(plan, "allow", patch_count); - emit_evaluation_log(plan, result, "allow", patch_count); - Ok(candidate) - } - Err(err) => { - apply_failure_policy(plan, &err)?; - Ok(operation) - } - } - } else { - emit_evaluation_metrics(plan, "allow", 0); - emit_evaluation_log(plan, result, "allow", 0); - Ok(operation) - } -} - -impl GatewayInterceptorProfileSource { - #[must_use] - pub fn source_id(&self) -> &str { - &self.source_id - } - - pub async fn snapshot(&self) -> Result { - let mut client = self.client.clone(); - let response = tokio::time::timeout( - self.timeout, - client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), - ) - .await - .map_err(|_| { - InterceptorError::Transport(format!( - "SnapshotProviderProfiles timed out for '{}'", - self.interceptor_name - )) - })? - .map_err(|status| { - InterceptorError::Transport(format!( - "SnapshotProviderProfiles failed for '{}': {status}", - self.interceptor_name - )) - })? - .into_inner(); - - let revision = if response.revision.trim().is_empty() { - provider_profile_snapshot_revision(&response.profiles) - } else { - response.revision - }; - Ok(ProviderProfileSourceSnapshot { - revision, - profiles: response.profiles, - }) - } -} - -#[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 validate_interceptor_configs(configs: &[GatewayInterceptorConfig]) -> Result<()> { - let mut names = BTreeSet::new(); - for config in configs { - validate_service_config(config)?; - if !names.insert(config.name.clone()) { - return Err(InterceptorError::Config(format!( - "duplicate interceptor instance name '{}'", - config.name - ))); - } - } - 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_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)? - && 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(); - } - } - - if phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { - return Err(InterceptorError::Config(format!( - "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", - failure_policy.as_str() - ))); - } - - 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 provider_profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { - let mut profiles = profiles.to_vec(); - profiles.sort_by(|left, right| left.id.cmp(&right.id)); - let mut hasher = sha2::Sha256::new(); - hasher.update(b"openshell-provider-profile-snapshot-v1"); - for profile in profiles { - hasher.update(profile.encode_to_vec()); - } - format!("sha256:{:x}", hasher.finalize()) -} - -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_optional_failure_policy(value: &str) -> Result> { - match value.trim() { - "" => Ok(None), - "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), - "fail_open" => Ok(Some(FailurePolicy::FailOpen)), - 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 = if plan.phase == Phase::Validate { - context - .validate_current_state - .clone() - .map(json_to_struct) - .transpose()? - } else { - None - }; - 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(), - principal: context.principal.clone().into_iter().collect(), - phase: Some(phase_evaluation(plan.phase, operation, current_state)), - }; - - 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 phase_evaluation( - phase: Phase, - proposed_operation: Struct, - current_state: Option, -) -> interceptor_evaluation::Phase { - match phase { - Phase::ModifyOperation => { - interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { - proposed_operation: Some(proposed_operation), - }) - } - Phase::Validate => interceptor_evaluation::Phase::Validate(ValidateEvaluation { - proposed_operation: Some(proposed_operation), - current_state, - }), - Phase::PostCommit => interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { - committed_response: Some(proposed_operation), - }), - } -} - -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(()) - } - } -} - -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 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", - "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", - "interceptor" => plan.interceptor_name.clone(), - "binding_id" => plan.binding_id.clone(), - ) - .increment(patch_count as u64); - } -} - -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, - message: Vec, -} - -impl GrpcFrame { - fn decode(body: &[u8]) -> std::result::Result { - if body.len() < GRPC_HEADER_LEN { - return Err(Status::invalid_argument("gRPC frame is too short")); - } - let compressed = body[0] != 0; - if compressed { - return Err(Status::unimplemented( - "gateway interceptors do not support compressed gRPC frames", - )); - } - 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 body 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 oneofs = message - .oneof_decl - .iter() - .enumerate() - .map(|(index, oneof)| { - let name = oneof.name.clone().unwrap_or_default(); - if name.is_empty() { - return Err(InterceptorError::Config(format!( - "oneof {index} on message '{full_name}' has no name" - ))); - } - Ok(OneofDesc { - name, - field_numbers: BTreeSet::new(), - }) - }) - .collect::>>()?; - let mut fields = BTreeMap::new(); - let mut fields_by_json = HashMap::new(); - for field in &message.field { - let field_desc = FieldDesc::from_proto(field)?; - if let Some(oneof_index) = field_desc.oneof_index { - let oneof = oneofs.get_mut(oneof_index as usize).ok_or_else(|| { - InterceptorError::Config(format!( - "field '{}' on message '{full_name}' references invalid oneof index {oneof_index}", - field_desc.name - )) - })?; - if field_desc.repeated { - return Err(InterceptorError::Config(format!( - "repeated field '{}' on message '{full_name}' cannot belong to oneof '{}'", - field_desc.name, oneof.name - ))); - } - oneof.field_numbers.insert(field_desc.number); - } - 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, - oneofs, - 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 { - if let Some(value) = decode_well_known_json(type_name, bytes) { - self.validate_message_oneofs(type_name, bytes)?; - return value; - } - - let message = self.message(type_name)?; - let mut values: HashMap> = HashMap::new(); - let mut selected_oneofs = 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; - }; - message.select_oneof(type_name, field, &mut selected_oneofs)?; - 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 validate_message_oneofs(&self, type_name: &str, bytes: &[u8]) -> Result<()> { - let message = self.message(type_name)?; - let mut selected_oneofs = 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; - }; - message.select_oneof(type_name, field, &mut selected_oneofs)?; - if field.kind == FieldKind::Message && wire_type == 2 { - let bytes = decode_length_delimited(&mut input)?; - let nested_type = field.type_name.as_deref().ok_or_else(|| { - InterceptorError::Transcode(format!( - "message field {} lacks type_name", - field.name - )) - })?; - self.validate_message_oneofs(nested_type, &bytes)?; - } else { - skip_unknown(wire_type, &mut input)?; - } - } - Ok(()) - } - - 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> { - 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!( - "{type_name} JSON must be an object" - ))); - }; - let mut fields = Vec::with_capacity(map.len()); - let mut selected_oneofs = HashMap::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"); - message.select_oneof(type_name, field, &mut selected_oneofs)?; - fields.push((field, value)); - } - - let mut out = Vec::new(); - for (field, value) in fields { - 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, - oneofs: Vec, - map_entry: bool, -} - -impl MessageDesc { - fn select_oneof( - &self, - type_name: &str, - field: &FieldDesc, - selected: &mut HashMap, - ) -> Result<()> { - let Some(oneof_index) = field.oneof_index else { - return Ok(()); - }; - let oneof = self - .oneofs - .get(oneof_index as usize) - .expect("oneof indexes are validated when descriptors are loaded"); - debug_assert!(oneof.field_numbers.contains(&field.number)); - if let Some(previous_number) = selected.insert(oneof_index, field.number) - && previous_number != field.number - { - let previous = self - .fields - .get(&previous_number) - .expect("oneof member field is indexed"); - return Err(InterceptorError::Transcode(format!( - "message '{type_name}' sets multiple fields in oneof '{}': '{}' and '{}'", - oneof.name, previous.name, field.name - ))); - } - Ok(()) - } -} - -#[derive(Debug, Clone)] -struct OneofDesc { - name: String, - field_numbers: BTreeSet, -} - -#[derive(Debug, Clone)] -struct FieldDesc { - name: String, - json_name: String, - number: u32, - repeated: bool, - kind: FieldKind, - type_name: Option, - oneof_index: 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()); - let oneof_index = field - .oneof_index - .map(|index| { - u32::try_from(index).map_err(|_| { - InterceptorError::Config(format!( - "field '{name}' has invalid oneof index {index}" - )) - }) - }) - .transpose()?; - Ok(Self { - name, - json_name, - number, - repeated, - kind, - type_name, - oneof_index, - }) - } - - 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_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), - 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 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)) => protobuf_struct_to_json(value), - Some(prost_types::value::Kind::ListValue(value)) => protobuf_list_value_to_json(value), - } -} - -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, GpuResourceRequirements, SandboxSpec, SandboxTemplate, - UpdateConfigRequest, - }; - use serde_json::json; - use std::sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - }; - 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(()) - } - } - - #[derive(Debug)] - struct AtomicCounter(Arc); - - impl metrics::CounterFn for AtomicCounter { - fn increment(&self, value: u64) { - self.0.fetch_add(value, Ordering::Relaxed); - } - - fn absolute(&self, value: u64) { - self.0.fetch_max(value, Ordering::Relaxed); - } - } - - #[derive(Debug, Default)] - struct TestRecorder { - evaluations: Arc, - patches: Arc, - fail_open: Arc, - fail_closed: Arc, - } - - impl metrics::Recorder for TestRecorder { - fn describe_counter( - &self, - _key: metrics::KeyName, - _unit: Option, - _description: metrics::SharedString, - ) { - } - - fn describe_gauge( - &self, - _key: metrics::KeyName, - _unit: Option, - _description: metrics::SharedString, - ) { - } - - fn describe_histogram( - &self, - _key: metrics::KeyName, - _unit: Option, - _description: metrics::SharedString, - ) { - } - - fn register_counter( - &self, - key: &metrics::Key, - _metadata: &metrics::Metadata<'_>, - ) -> metrics::Counter { - let counter = match key.name() { - "openshell_gateway_interceptor_evaluations_total" => &self.evaluations, - "openshell_gateway_interceptor_patches_total" => &self.patches, - "openshell_gateway_interceptor_fail_open_total" => &self.fail_open, - "openshell_gateway_interceptor_fail_closed_total" => &self.fail_closed, - _ => return metrics::Counter::noop(), - }; - metrics::Counter::from_arc(Arc::new(AtomicCounter(counter.clone()))) - } - - fn register_gauge( - &self, - _key: &metrics::Key, - _metadata: &metrics::Metadata<'_>, - ) -> metrics::Gauge { - metrics::Gauge::noop() - } - - fn register_histogram( - &self, - _key: &metrics::Key, - _metadata: &metrics::Metadata<'_>, - ) -> metrics::Histogram { - metrics::Histogram::noop() - } - } - - impl TestRecorder { - fn count(counter: &AtomicU64) -> u64 { - counter.load(Ordering::Relaxed) - } - } - - fn test_modify_plan(failure_policy: FailurePolicy) -> BindingPlan { - BindingPlan { - interceptor_name: "test".to_string(), - binding_id: "binding".to_string(), - selector: RpcSelector { - service: "openshell.v1.OpenShell".to_string(), - method: "UpdateConfig".to_string(), - }, - phase: Phase::ModifyOperation, - failure_policy, - 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(), - ), - } - } - - fn patch(op: &str, path: &str, value: Value) -> JsonPatch { - JsonPatch { - op: op.to_string(), - path: path.to_string(), - value: Some(json_to_protobuf_value(value).unwrap()), - from: String::new(), - } - } - - fn allowed_result(patches: Vec) -> InterceptorResult { - InterceptorResult { - allowed: true, - patches, - ..InterceptorResult::default() - } - } - - #[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 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 duplicate_interceptor_instance_names_are_invalid() { - let config = GatewayInterceptorConfig { - name: "governance".to_string(), - grpc_endpoint: "http://127.0.0.1:18081".to_string(), - ..GatewayInterceptorConfig::default() - }; - let err = validate_interceptor_configs(&[config.clone(), config]).unwrap_err(); - - assert_eq!( - err.to_string(), - "invalid interceptor config: duplicate interceptor instance name 'governance'" - ); - } - - #[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 post_commit_binding_rejects_fail_closed() { - let overrides = Vec::new(); - let override_index = OverrideIndex::new(&overrides).unwrap(); - let binding = InterceptorBinding { - id: "audit-create-sandbox".to_string(), - selector: Some(InterceptorSelector { - rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), - service: String::new(), - method: String::new(), - }), - phases: vec![GatewayInterceptorPhase::PostCommit as i32], - failure_policy: "fail_closed".to_string(), - }; - - let err = normalize_binding( - "audit", - &binding, - FailurePolicy::FailClosed, - &override_index, - ) - .expect_err("post_commit must not fail closed after an operation commits"); - - assert_eq!( - err.to_string(), - "invalid interceptor config: interceptor 'audit' binding 'audit-create-sandbox' uses failure_policy=fail_closed for post_commit; post_commit must use fail_open" - ); - } - - #[test] - fn post_commit_binding_accepts_fail_open() { - let overrides = Vec::new(); - let override_index = OverrideIndex::new(&overrides).unwrap(); - let binding = InterceptorBinding { - id: "audit-create-sandbox".to_string(), - selector: Some(InterceptorSelector { - rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), - service: String::new(), - method: String::new(), - }), - phases: vec![GatewayInterceptorPhase::PostCommit as i32], - failure_policy: "fail_open".to_string(), - }; - - let normalized = normalize_binding( - "audit", - &binding, - FailurePolicy::FailClosed, - &override_index, - ) - .expect("fail-open post_commit binding should be valid") - .expect("binding should be enabled"); - - assert_eq!(normalized.failure_policy, FailurePolicy::FailOpen); - } - - #[test] - fn phase_evaluations_use_the_matching_oneof_variant() { - let operation = json_to_struct(json!({"name": "demo"})).unwrap(); - - let modify = phase_evaluation(Phase::ModifyOperation, operation.clone(), None); - let interceptor_evaluation::Phase::ModifyOperation(payload) = modify else { - panic!("expected modify_operation payload"); - }; - assert_eq!(payload.proposed_operation, Some(operation.clone())); - - let validate = phase_evaluation(Phase::Validate, operation.clone(), None); - let interceptor_evaluation::Phase::Validate(payload) = validate else { - panic!("expected validate payload"); - }; - assert_eq!(payload.proposed_operation, Some(operation.clone())); - assert!(payload.current_state.is_none()); - - let validate = - phase_evaluation(Phase::Validate, operation.clone(), Some(Struct::default())); - let interceptor_evaluation::Phase::Validate(payload) = validate else { - panic!("expected validate payload"); - }; - assert_eq!(payload.current_state, Some(Struct::default())); - - let current_state = json_to_struct(json!({"resourceVersion": "7"})).unwrap(); - let validate = phase_evaluation( - Phase::Validate, - operation.clone(), - Some(current_state.clone()), - ); - let interceptor_evaluation::Phase::Validate(payload) = validate else { - panic!("expected validate payload"); - }; - assert_eq!(payload.current_state, Some(current_state.clone())); - - let post_commit = - phase_evaluation(Phase::PostCommit, operation.clone(), Some(current_state)); - let interceptor_evaluation::Phase::PostCommit(payload) = post_commit else { - panic!("expected post_commit payload"); - }; - assert_eq!(payload.committed_response, Some(operation)); - } - - #[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 = - 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())]), - 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"]["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); - } - - #[test] - fn dynamic_update_config_round_trip_preserves_annotations() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let request = UpdateConfigRequest { - name: "demo".to_string(), - annotations: HashMap::from([( - "openshell.nvidia.com/policy-signature".to_string(), - "signed".to_string(), - )]), - ..Default::default() - }; - let bytes = request.encode_to_vec(); - let json = descriptors - .decode_message_to_json("openshell.v1.UpdateConfigRequest", &bytes) - .unwrap(); - assert_eq!( - json["annotations"]["openshell.nvidia.com/policy-signature"], - "signed" - ); - let encoded = descriptors - .encode_json_to_message("openshell.v1.UpdateConfigRequest", &json) - .unwrap(); - let decoded = UpdateConfigRequest::decode(encoded.as_slice()).unwrap(); - assert_eq!(decoded, request); - } - - #[test] - fn descriptor_loading_rejects_invalid_oneof_indexes() { - let descriptor_set = FileDescriptorSet { - file: vec![FileDescriptorProto { - package: Some("test".to_string()), - message_type: vec![DescriptorProto { - name: Some("Broken".to_string()), - field: vec![FieldDescriptorProto { - name: Some("value".to_string()), - number: Some(1), - label: Some(Label::Optional as i32), - r#type: Some(Type::String as i32), - oneof_index: Some(0), - ..FieldDescriptorProto::default() - }], - ..DescriptorProto::default() - }], - ..FileDescriptorProto::default() - }], - }; - - let err = ProtoDescriptors::from_descriptor_set(&descriptor_set.encode_to_vec()) - .expect_err("out-of-range oneof index must be rejected"); - - assert!(err.to_string().contains("test.Broken")); - assert!(err.to_string().contains("invalid oneof index 0")); - } - - #[test] - fn dynamic_codec_rejects_ambiguous_policy_merge_oneof_wire_input() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let add_then_remove = vec![0x0a, 0x00, 0x1a, 0x00]; - let remove_then_add = vec![0x1a, 0x00, 0x0a, 0x00]; - - for bytes in [add_then_remove, remove_then_add] { - let err = descriptors - .decode_message_to_json("openshell.v1.PolicyMergeOperation", &bytes) - .expect_err("multiple operation alternatives must be rejected"); - let message = err.to_string(); - assert!(message.contains("openshell.v1.PolicyMergeOperation")); - assert!(message.contains("oneof 'operation'")); - assert!(message.contains("add_rule")); - assert!(message.contains("remove_rule")); - } - } - - #[test] - fn dynamic_codec_rejects_ambiguous_interceptor_phase_wire_input() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let modify_then_validate = vec![0x32, 0x00, 0x3a, 0x00]; - - let err = descriptors - .decode_message_to_json( - "openshell.gateway_interceptor.v1.InterceptorEvaluation", - &modify_then_validate, - ) - .expect_err("multiple phase alternatives must be rejected"); - - let message = err.to_string(); - assert!(message.contains("oneof 'phase'")); - assert!(message.contains("modify_operation")); - assert!(message.contains("validate")); - } - - #[test] - fn dynamic_codec_rejects_ambiguous_well_known_value_wire_input() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let mut string_then_bool = Vec::new(); - encode_key(3, 2, &mut string_then_bool); - encode_length_delimited(b"text", &mut string_then_bool).unwrap(); - encode_key(4, 0, &mut string_then_bool); - encode_varint(1, &mut string_then_bool); - - let err = descriptors - .decode_message_to_json("google.protobuf.Value", &string_then_bool) - .expect_err("well-known Value alternatives must be rejected"); - - let message = err.to_string(); - assert!(message.contains("google.protobuf.Value")); - assert!(message.contains("oneof 'kind'")); - assert!(message.contains("string_value")); - assert!(message.contains("bool_value")); - } - - #[test] - fn dynamic_codec_rejects_ambiguous_value_nested_in_well_known_struct() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let mut ambiguous_value = Vec::new(); - encode_key(3, 2, &mut ambiguous_value); - encode_length_delimited(b"text", &mut ambiguous_value).unwrap(); - encode_key(4, 0, &mut ambiguous_value); - encode_varint(1, &mut ambiguous_value); - - let mut map_entry = Vec::new(); - encode_key(1, 2, &mut map_entry); - encode_length_delimited(b"key", &mut map_entry).unwrap(); - encode_key(2, 2, &mut map_entry); - encode_length_delimited(&ambiguous_value, &mut map_entry).unwrap(); - - let mut struct_bytes = Vec::new(); - encode_key(1, 2, &mut struct_bytes); - encode_length_delimited(&map_entry, &mut struct_bytes).unwrap(); - - let err = descriptors - .decode_message_to_json("google.protobuf.Struct", &struct_bytes) - .expect_err("nested well-known Value alternatives must be rejected"); - - assert!(err.to_string().contains("google.protobuf.Value")); - assert!(err.to_string().contains("oneof 'kind'")); - } - - #[test] - fn dynamic_codec_rejects_ambiguous_oneof_json_before_encoding() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - - let err = descriptors - .encode_json_to_message( - "openshell.v1.PolicyMergeOperation", - &json!({ - "addRule": {}, - "removeRule": {} - }), - ) - .expect_err("multiple JSON alternatives must be rejected"); - - assert!(err.to_string().contains("oneof 'operation'")); - } - - #[test] - fn dynamic_codec_round_trips_each_policy_merge_oneof_alternative() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - - for field in [ - "addRule", - "removeEndpoint", - "removeRule", - "addDenyRules", - "addAllowRules", - "removeBinary", - ] { - let json = json!({ (field): {} }); - let encoded = descriptors - .encode_json_to_message("openshell.v1.PolicyMergeOperation", &json) - .unwrap(); - let decoded = descriptors - .decode_message_to_json("openshell.v1.PolicyMergeOperation", &encoded) - .unwrap(); - let decoded = decoded - .as_object() - .expect("decoded oneof must be an object"); - assert_eq!(decoded.len(), 1); - assert!(decoded.contains_key(field)); - } - } - - #[test] - fn dynamic_codec_preserves_proto3_optional_presence() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - - for request in [ - GpuResourceRequirements { count: None }, - GpuResourceRequirements { count: Some(0) }, - GpuResourceRequirements { count: Some(2) }, - ] { - let json = descriptors - .decode_message_to_json( - "openshell.v1.GpuResourceRequirements", - &request.encode_to_vec(), - ) - .unwrap(); - let encoded = descriptors - .encode_json_to_message("openshell.v1.GpuResourceRequirements", &json) - .unwrap(); - let decoded = GpuResourceRequirements::decode(encoded.as_slice()).unwrap(); - assert_eq!(decoded, request); - } - } - - #[test] - fn dynamic_codec_rejects_nested_oneof_violations() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let ambiguous_merge = vec![0x0a, 0x00, 0x1a, 0x00]; - let mut update_config = Vec::new(); - encode_key(7, 2, &mut update_config); - encode_length_delimited(&ambiguous_merge, &mut update_config).unwrap(); - - let err = descriptors - .decode_message_to_json("openshell.v1.UpdateConfigRequest", &update_config) - .expect_err("nested oneof violation must be rejected"); - - assert!(err.to_string().contains("PolicyMergeOperation")); - assert!(err.to_string().contains("oneof 'operation'")); - } - - #[tokio::test] - async fn request_middleware_rejects_ambiguous_oneof_before_evaluation() { - let selector = RpcSelector { - service: "openshell.v1.OpenShell".to_string(), - method: "UpdateConfig".to_string(), - }; - let plan = BindingPlan { - interceptor_name: "must-not-run".to_string(), - binding_id: "must-not-run".to_string(), - selector: selector.clone(), - 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 runtime = GatewayInterceptorRuntime { - bindings: Arc::new(BTreeMap::from([( - (selector, Phase::ModifyOperation), - vec![plan], - )])), - profile_sources: Arc::new(BTreeMap::new()), - routes: Arc::new( - routes::OpenShellRouteIndex::from_descriptor_set( - openshell_core::FILE_DESCRIPTOR_SET, - ) - .unwrap(), - ), - descriptors: Arc::new( - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(), - ), - }; - let ambiguous_merge = vec![0x0a, 0x00, 0x1a, 0x00]; - let mut request = Vec::new(); - encode_key(7, 2, &mut request); - encode_length_delimited(&ambiguous_merge, &mut request).unwrap(); - let body = GrpcFrame { - compressed: false, - message: request, - } - .encode() - .unwrap(); - - let err = runtime - .evaluate_request( - "/openshell.v1.OpenShell/UpdateConfig", - &body, - &EvaluationContext { - principal: BTreeMap::new(), - validate_current_state: None, - }, - ) - .await - .expect_err("ambiguous request must fail before interceptor evaluation"); - - assert_eq!(err.code(), Code::InvalidArgument); - assert!(err.message().contains("oneof 'operation'")); - } - - #[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" })); - } - - #[test] - fn validated_operation_rejects_message_repeated_and_enum_type_errors() { - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let cases = [ - ( - "openshell.v1.CreateSandboxRequest", - json!({"name": "demo", "spec": {}}), - patch("replace", "/spec", json!("not-a-message")), - ), - ( - "openshell.v1.CreateSandboxRequest", - json!({"name": "demo", "spec": {"providers": []}}), - patch("replace", "/spec/providers", json!({"provider": "github"})), - ), - ( - "openshell.v1.ReportPolicyStatusRequest", - json!({ - "sandboxId": "sandbox-id", - "version": 1, - "status": "POLICY_STATUS_LOADED" - }), - patch("replace", "/status", json!("POLICY_STATUS_UNKNOWN")), - ), - ]; - - for (type_name, json, invalid_patch) in cases { - let operation = ValidatedOperation::new(&descriptors, type_name, json).unwrap(); - let err = operation - .apply_patches(&descriptors, type_name, &[invalid_patch]) - .expect_err("wrong-type patch must not produce a validated operation"); - - assert!(matches!(err, InterceptorError::InvalidResult(_))); - assert!(err.to_string().contains("patched operation is not valid")); - } - } - - #[tokio::test] - async fn wrong_type_patch_fails_open_to_exact_prior_operation_and_metrics() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), - ) - .unwrap(); - let prior = operation.clone(); - let plan = test_modify_plan(FailurePolicy::FailOpen); - let result = allowed_result(vec![patch( - "replace", - "/expectedResourceVersion", - json!("not-a-number"), - )]); - let recorder = TestRecorder::default(); - - let outcome = metrics::with_local_recorder(&recorder, || { - apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &plan, - &result, - operation, - ) - }) - .unwrap(); - - assert_eq!(outcome, prior); - assert_eq!(TestRecorder::count(&recorder.fail_open), 1); - assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); - assert_eq!(TestRecorder::count(&recorder.evaluations), 0); - assert_eq!(TestRecorder::count(&recorder.patches), 0); - } - - #[tokio::test] - async fn wrong_type_patch_fails_closed_before_dispatch_and_metrics() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), - ) - .unwrap(); - let plan = test_modify_plan(FailurePolicy::FailClosed); - let result = allowed_result(vec![patch( - "replace", - "/expectedResourceVersion", - json!("not-a-number"), - )]); - let recorder = TestRecorder::default(); - - let status = metrics::with_local_recorder(&recorder, || { - apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &plan, - &result, - operation, - ) - }) - .expect_err("fail-closed invalid candidate must stop dispatch"); - - assert_eq!(status.code(), Code::PermissionDenied); - assert!(status.message().contains("patched operation is not valid")); - assert_eq!(TestRecorder::count(&recorder.fail_open), 0); - assert_eq!(TestRecorder::count(&recorder.fail_closed), 1); - assert_eq!(TestRecorder::count(&recorder.evaluations), 0); - assert_eq!(TestRecorder::count(&recorder.patches), 0); - } - - #[tokio::test] - async fn invalid_patch_list_is_atomic() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), - ) - .unwrap(); - let prior = operation.clone(); - let result = allowed_result(vec![ - patch("replace", "/name", json!("partially-mutated")), - patch("replace", "/expectedResourceVersion", json!("not-a-number")), - ]); - - let outcome = apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &test_modify_plan(FailurePolicy::FailOpen), - &result, - operation, - ) - .unwrap(); - - assert_eq!(outcome, prior); - assert_eq!(outcome.json["name"], "demo"); - } - - #[tokio::test] - async fn later_binding_sees_pre_binding_value_after_invalid_candidate_fails_open() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), - ) - .unwrap(); - let plan = test_modify_plan(FailurePolicy::FailOpen); - let invalid_first = allowed_result(vec![ - patch("replace", "/name", json!("rejected-candidate")), - patch("replace", "/expectedResourceVersion", json!("not-a-number")), - ]); - let second = allowed_result(vec![ - patch("test", "/name", json!("demo")), - patch("replace", "/name", json!("accepted-candidate")), - ]); - - let operation = apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &plan, - &invalid_first, - operation, - ) - .unwrap(); - let operation = apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &plan, - &second, - operation, - ) - .unwrap(); - - assert_eq!(operation.json["name"], "accepted-candidate"); - let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); - assert_eq!(decoded.name, "accepted-candidate"); - assert_eq!(decoded.expected_resource_version, 7); - } - - #[tokio::test] - async fn ambiguous_oneof_patch_obeys_binding_failure_policy() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.PolicyMergeOperation", - json!({"addRule": {}}), - ) - .unwrap(); - let result = allowed_result(vec![patch("add", "/removeRule", json!({}))]); - - let fail_open = apply_evaluation_result( - &descriptors, - "openshell.v1.PolicyMergeOperation", - &test_modify_plan(FailurePolicy::FailOpen), - &result, - operation.clone(), - ) - .unwrap(); - let fail_closed = apply_evaluation_result( - &descriptors, - "openshell.v1.PolicyMergeOperation", - &test_modify_plan(FailurePolicy::FailClosed), - &result, - operation.clone(), - ) - .expect_err("ambiguous oneof candidate must fail closed"); - - assert_eq!(fail_open, operation); - assert_eq!(fail_closed.code(), Code::PermissionDenied); - assert!(fail_closed.message().contains("oneof 'operation'")); - } - - #[tokio::test] - async fn accepted_candidate_updates_encoded_operation_and_patch_metrics() { - tokio::task::yield_now().await; - let descriptors = - ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); - let operation = ValidatedOperation::new( - &descriptors, - "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), - ) - .unwrap(); - let result = allowed_result(vec![patch("replace", "/name", json!("accepted"))]); - let recorder = TestRecorder::default(); - - let operation = metrics::with_local_recorder(&recorder, || { - apply_evaluation_result( - &descriptors, - "openshell.v1.UpdateConfigRequest", - &test_modify_plan(FailurePolicy::FailClosed), - &result, - operation, - ) - }) - .unwrap(); - - let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); - assert_eq!(decoded.name, "accepted"); - assert_eq!(TestRecorder::count(&recorder.evaluations), 1); - assert_eq!(TestRecorder::count(&recorder.patches), 1); - assert_eq!(TestRecorder::count(&recorder.fail_open), 0); - assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); - } -} diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs new file mode 100644 index 0000000000..c4038969ba --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -0,0 +1,737 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptor configuration and immutable execution planning. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::time::Duration; + +use hyper_util::rt::TokioIo; +use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayInterceptorPhaseConfig, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use tokio::net::UnixStream; +use tonic::Request; +use tonic::codegen::http::Uri; +use tonic::transport::{Channel, Endpoint}; +use tower::service_fn; +use tracing::info; + +use crate::profile_source::GatewayInterceptorProfileSource; +use crate::routes::OpenShellRouteIndex; +use crate::{InterceptorError, Result}; + +pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; +pub const DEFAULT_MAX_PATCHES: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + ModifyOperation, + Validate, + PostCommit, +} + +impl Phase { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ModifyOperation => "modify_operation", + Self::Validate => "validate", + Self::PostCommit => "post_commit", + } + } +} + +impl TryFrom for Phase { + type Error = InterceptorError; + + fn try_from(value: GatewayInterceptorPhase) -> Result { + match value { + 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::ModifyOperation => Self::ModifyOperation, + GatewayInterceptorPhaseConfig::Validate => Self::Validate, + GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + FailClosed, + FailOpen, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + } + } +} + +impl From for FailurePolicy { + fn from(value: GatewayInterceptorFailurePolicy) -> Self { + match value { + GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, + GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, + } + } +} + +#[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)] +pub struct BindingPlan { + pub(crate) interceptor_name: String, + pub(crate) binding_id: String, + pub(crate) selector: RpcSelector, + pub(crate) phase: Phase, + pub(crate) failure_policy: FailurePolicy, + pub(crate) timeout: Duration, + pub(crate) max_response_bytes: usize, + pub(crate) max_patches: usize, + pub(crate) 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)] +pub struct ExecutionPlan { + bindings: BTreeMap<(RpcSelector, Phase), Vec>, + profile_sources: BTreeMap, + routes: OpenShellRouteIndex, +} + +impl ExecutionPlan { + #[cfg(test)] + pub(crate) fn empty(routes: OpenShellRouteIndex) -> Self { + Self { + bindings: BTreeMap::new(), + profile_sources: BTreeMap::new(), + routes, + } + } + + pub(crate) async fn load( + mut configs: Vec, + routes: OpenShellRouteIndex, + ) -> Result { + validate_interceptor_configs(&configs)?; + configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); + + let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); + let mut profile_sources = BTreeMap::new(); + + for config in configs { + 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 = + 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 manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; + let service_default = config + .failure_policy + .map(FailurePolicy::from) + .or(manifest_default) + .unwrap_or(FailurePolicy::FailClosed); + 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 { + 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); + } + } + + if manifest.provider_profiles { + let source_id = format!("interceptor/{}", config.name); + profile_sources.insert( + config.name.clone(), + GatewayInterceptorProfileSource::new( + config.name.clone(), + source_id, + timeout, + GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + ), + ); + } + } + + let count: usize = bindings.values().map(Vec::len).sum(); + info!( + bindings = count, + profile_sources = profile_sources.len(), + "gateway interceptors initialized" + ); + Ok(Self { + bindings, + profile_sources, + routes, + }) + } + + pub(crate) fn profile_source( + &self, + interceptor_name: &str, + ) -> Option { + self.profile_sources.get(interceptor_name).cloned() + } + + pub(crate) fn is_empty(&self) -> bool { + self.bindings.is_empty() && self.profile_sources.is_empty() + } + + pub(crate) fn should_intercept(&self, selector: &RpcSelector) -> bool { + self.routes + .is_interceptable(&selector.service, &selector.method) + && [Phase::ModifyOperation, Phase::Validate, Phase::PostCommit] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + } + + pub(crate) fn input_type(&self, selector: &RpcSelector) -> Option<&str> { + self.routes.input_type(&selector.service, &selector.method) + } + + pub(crate) fn output_type(&self, selector: &RpcSelector) -> Option<&str> { + self.routes.output_type(&selector.service, &selector.method) + } + + pub(crate) fn bindings(&self, selector: &RpcSelector, phase: Phase) -> Option<&[BindingPlan]> { + self.bindings + .get(&(selector.clone(), phase)) + .map(Vec::as_slice) + } + + pub(crate) fn has_binding(&self, selector: &RpcSelector, phase: Phase) -> bool { + self.bindings.contains_key(&(selector.clone(), phase)) + } +} + +#[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 validate_interceptor_configs(configs: &[GatewayInterceptorConfig]) -> Result<()> { + let mut names = BTreeSet::new(); + for config in configs { + validate_service_config(config)?; + if !names.insert(config.name.clone()) { + return Err(InterceptorError::Config(format!( + "duplicate interceptor instance name '{}'", + config.name + ))); + } + } + 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_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)? + && 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(); + } + } + + if phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", + failure_policy.as_str() + ))); + } + + 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_optional_failure_policy(value: &str) -> Result> { + match value.trim() { + "" => Ok(None), + "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), + "fail_open" => Ok(Some(FailurePolicy::FailOpen)), + 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() + ))) +} + +#[cfg(test)] +mod tests { + use openshell_core::config::GatewayInterceptorConfig; + use openshell_core::proto::gateway_interceptor::v1::{ + GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, + }; + + use super::*; + + #[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 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 duplicate_interceptor_instance_names_are_invalid() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + ..GatewayInterceptorConfig::default() + }; + let err = validate_interceptor_configs(&[config.clone(), config]).unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: duplicate interceptor instance name 'governance'" + ); + } + + #[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 post_commit_binding_rejects_fail_closed() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_closed".to_string(), + }; + + let err = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect_err("post_commit must not fail closed after an operation commits"); + + assert_eq!( + err.to_string(), + "invalid interceptor config: interceptor 'audit' binding 'audit-create-sandbox' uses failure_policy=fail_closed for post_commit; post_commit must use fail_open" + ); + } + + #[test] + fn post_commit_binding_accepts_fail_open() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_open".to_string(), + }; + + let normalized = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect("fail-open post_commit binding should be valid") + .expect("binding should be enabled"); + + assert_eq!(normalized.failure_policy, FailurePolicy::FailOpen); + } +} diff --git a/crates/openshell-gateway-interceptors/src/profile_source.rs b/crates/openshell-gateway-interceptors/src/profile_source.rs new file mode 100644 index 0000000000..74cd3b26c2 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptor-vended provider profile snapshots. + +use std::time::Duration; + +use openshell_core::proto::ProviderProfile; +use openshell_core::proto::gateway_interceptor::v1::{ + ProviderProfileSnapshotRequest, gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost::Message as _; +use sha2::Digest as _; +use tonic::{Request, transport::Channel}; + +use crate::{InterceptorError, Result}; + +#[derive(Debug, Clone)] +pub struct ProviderProfileSourceSnapshot { + pub revision: String, + pub profiles: Vec, +} + +#[derive(Clone)] +pub struct GatewayInterceptorProfileSource { + interceptor_name: String, + source_id: String, + timeout: Duration, + client: GatewayInterceptorClient, +} + +impl GatewayInterceptorProfileSource { + pub(crate) fn new( + interceptor_name: String, + source_id: String, + timeout: Duration, + client: GatewayInterceptorClient, + ) -> Self { + Self { + interceptor_name, + source_id, + timeout, + client, + } + } + + #[must_use] + pub fn source_id(&self) -> &str { + &self.source_id + } + + pub async fn snapshot(&self) -> Result { + let mut client = self.client.clone(); + let response = tokio::time::timeout( + self.timeout, + client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), + ) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles timed out for '{}'", + self.interceptor_name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles failed for '{}': {status}", + self.interceptor_name + )) + })? + .into_inner(); + + let revision = if response.revision.trim().is_empty() { + provider_profile_snapshot_revision(&response.profiles) + } else { + response.revision + }; + Ok(ProviderProfileSourceSnapshot { + revision, + profiles: response.profiles, + }) + } +} + +impl std::fmt::Debug for GatewayInterceptorProfileSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayInterceptorProfileSource") + .field("interceptor_name", &self.interceptor_name) + .field("source_id", &self.source_id) + .field("timeout", &self.timeout) + .finish_non_exhaustive() + } +} + +fn provider_profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = sha2::Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs new file mode 100644 index 0000000000..78756b60e4 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Descriptor-backed protobuf and `ProtoJSON` conversion. + +use std::sync::Arc; + +use prost::Message as _; +use prost_reflect::{DescriptorPool, DeserializeOptions, DynamicMessage, MessageDescriptor}; +use serde_json::Value; + +use crate::{InterceptorError, Result}; + +/// Descriptor-backed protobuf/JSON codec used at the interceptor seam. +/// +/// Binary input follows protobuf's standard semantics, including +/// last-member-wins handling when multiple alternatives from one `oneof` +/// appear on the wire. JSON input follows the canonical `ProtoJSON` mapping. +#[derive(Debug, Clone)] +pub struct ProtoJsonCodec { + pool: Arc, +} + +impl ProtoJsonCodec { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + let pool = DescriptorPool::decode(bytes).map_err(|err| { + InterceptorError::Config(format!("decode protobuf descriptor set: {err}")) + })?; + Ok(Self { + pool: Arc::new(pool), + }) + } + + pub fn openshell() -> Result { + Self::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + } + + pub fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: prost::Message, + { + self.decode_bytes_to_json(type_name, &message.encode_to_vec()) + } + + pub fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + let descriptor = self.message_descriptor(type_name)?; + let encoded_json = serde_json::to_vec(value).map_err(|err| { + InterceptorError::Transcode(format!( + "serialize {type_name} JSON before protobuf encoding: {err}" + )) + })?; + let mut deserializer = serde_json::Deserializer::from_slice(&encoded_json); + let message = DynamicMessage::deserialize_with_options( + descriptor, + &mut deserializer, + &DeserializeOptions::new(), + ) + .map_err(|err| { + InterceptorError::Transcode(format!("encode {type_name} from ProtoJSON: {err}")) + })?; + deserializer.end().map_err(|err| { + InterceptorError::Transcode(format!("encode {type_name} from ProtoJSON: {err}")) + })?; + Ok(message.encode_to_vec()) + } + + pub(crate) fn descriptor_pool(&self) -> &DescriptorPool { + &self.pool + } + + pub(crate) fn decode_bytes_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + let descriptor = self.message_descriptor(type_name)?; + let message = DynamicMessage::decode(descriptor, bytes).map_err(|err| { + InterceptorError::Transcode(format!("decode {type_name} protobuf message: {err}")) + })?; + serde_json::to_value(message).map_err(|err| { + InterceptorError::Transcode(format!("serialize {type_name} as ProtoJSON: {err}")) + }) + } + + fn message_descriptor(&self, type_name: &str) -> Result { + let normalized = type_name.strip_prefix('.').unwrap_or(type_name); + self.pool.get_message_by_name(normalized).ok_or_else(|| { + InterceptorError::Transcode(format!( + "protobuf message type '{normalized}' was not found in the descriptor set" + )) + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use openshell_core::proto::{ + CreateSandboxRequest, GpuResourceRequirements, SandboxSpec, UpdateConfigRequest, + }; + use prost::Message as _; + use prost_types::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, + }; + use serde_json::json; + + use super::*; + use crate::InterceptorError; + + #[test] + fn dynamic_create_sandbox_round_trip_uses_json_names() { + let codec = + ProtoJsonCodec::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())]), + annotations: HashMap::new(), + }; + let bytes = request.encode_to_vec(); + let json = codec + .decode_bytes_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + assert_eq!(json["spec"]["providers"][0], "github"); + assert_eq!(json["labels"]["team"], "agent"); + let encoded = codec + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn dynamic_update_config_round_trip_preserves_annotations() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = UpdateConfigRequest { + name: "demo".to_string(), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + let bytes = request.encode_to_vec(); + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &bytes) + .unwrap(); + assert_eq!( + json["annotations"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + let encoded = codec + .encode_json_to_message("openshell.v1.UpdateConfigRequest", &json) + .unwrap(); + let decoded = UpdateConfigRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn descriptor_loading_rejects_invalid_oneof_indexes() { + let descriptor_set = FileDescriptorSet { + file: vec![FileDescriptorProto { + package: Some("test".to_string()), + message_type: vec![DescriptorProto { + name: Some("Broken".to_string()), + field: vec![FieldDescriptorProto { + name: Some("value".to_string()), + number: Some(1), + label: Some(Label::Optional as i32), + r#type: Some(Type::String as i32), + oneof_index: Some(0), + ..FieldDescriptorProto::default() + }], + ..DescriptorProto::default() + }], + ..FileDescriptorProto::default() + }], + }; + + let err = ProtoJsonCodec::from_descriptor_set(&descriptor_set.encode_to_vec()) + .expect_err("out-of-range oneof index must be rejected"); + + assert!(matches!(err, InterceptorError::Config(_))); + assert!(err.to_string().contains("oneof")); + } + + #[test] + fn dynamic_codec_uses_last_policy_merge_oneof_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let add_then_remove = vec![0x0a, 0x00, 0x1a, 0x00]; + let remove_then_add = vec![0x1a, 0x00, 0x0a, 0x00]; + + for (bytes, selected, discarded) in [ + (add_then_remove, "removeRule", "addRule"), + (remove_then_add, "addRule", "removeRule"), + ] { + let json = codec + .decode_bytes_to_json("openshell.v1.PolicyMergeOperation", &bytes) + .unwrap(); + assert_eq!(json[selected], json!({})); + assert!(json.get(discarded).is_none()); + } + } + + #[test] + fn dynamic_codec_uses_last_interceptor_phase_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let modify_then_validate = vec![0x32, 0x00, 0x3a, 0x00]; + + let json = codec + .decode_bytes_to_json( + "openshell.gateway_interceptor.v1.InterceptorEvaluation", + &modify_then_validate, + ) + .unwrap(); + + assert_eq!(json["validate"], json!({})); + assert!(json.get("modifyOperation").is_none()); + } + + #[test] + fn dynamic_codec_uses_last_well_known_value_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let string_then_bool = [0x1a, 0x04, b't', b'e', b'x', b't', 0x20, 0x01]; + let bool_then_string = [0x20, 0x01, 0x1a, 0x04, b't', b'e', b'x', b't']; + + let bool_json = codec + .decode_bytes_to_json("google.protobuf.Value", &string_then_bool) + .unwrap(); + let string_json = codec + .decode_bytes_to_json("google.protobuf.Value", &bool_then_string) + .unwrap(); + + assert_eq!(bool_json, json!(true)); + assert_eq!(string_json, json!("text")); + } + + #[test] + fn dynamic_codec_uses_last_value_member_nested_in_well_known_struct() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let struct_bytes = [ + 0x0a, 0x0f, 0x0a, 0x03, b'k', b'e', b'y', 0x12, 0x08, 0x1a, 0x04, b't', b'e', b'x', + b't', 0x20, 0x01, + ]; + + let json = codec + .decode_bytes_to_json("google.protobuf.Struct", &struct_bytes) + .unwrap(); + + assert_eq!(json, json!({"key": true})); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_oneof_json_before_encoding() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + let err = codec + .encode_json_to_message( + "openshell.v1.PolicyMergeOperation", + &json!({ + "addRule": {}, + "removeRule": {} + }), + ) + .expect_err("multiple JSON alternatives must be rejected"); + + assert!(err.to_string().contains("oneof 'operation'")); + } + + #[test] + fn dynamic_codec_round_trips_each_policy_merge_oneof_alternative() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for field in [ + "addRule", + "removeEndpoint", + "removeRule", + "addDenyRules", + "addAllowRules", + "removeBinary", + ] { + let json = json!({ (field): {} }); + let encoded = codec + .encode_json_to_message("openshell.v1.PolicyMergeOperation", &json) + .unwrap(); + let decoded = codec + .decode_bytes_to_json("openshell.v1.PolicyMergeOperation", &encoded) + .unwrap(); + let decoded = decoded + .as_object() + .expect("decoded oneof must be an object"); + assert_eq!(decoded.len(), 1); + assert!(decoded.contains_key(field)); + } + } + + #[test] + fn dynamic_codec_preserves_proto3_optional_presence() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for request in [ + GpuResourceRequirements { count: None }, + GpuResourceRequirements { count: Some(0) }, + GpuResourceRequirements { count: Some(2) }, + ] { + let json = codec + .decode_bytes_to_json( + "openshell.v1.GpuResourceRequirements", + &request.encode_to_vec(), + ) + .unwrap(); + let encoded = codec + .encode_json_to_message("openshell.v1.GpuResourceRequirements", &json) + .unwrap(); + let decoded = GpuResourceRequirements::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + } + + #[test] + fn dynamic_codec_uses_last_nested_oneof_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let update_config = [0x3a, 0x04, 0x0a, 0x00, 0x1a, 0x00]; + + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &update_config) + .unwrap(); + + assert_eq!(json["mergeOperations"][0], json!({"removeRule": {}})); + } +} diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs index 65698d82e8..f8e2b6ca54 100644 --- a/crates/openshell-gateway-interceptors/src/routes.rs +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -5,8 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use prost::Message as _; -use prost_types::FileDescriptorSet; +use prost_reflect::DescriptorPool; use crate::{InterceptorError, Result}; @@ -59,46 +58,31 @@ pub struct OpenShellRouteIndex { impl OpenShellRouteIndex { pub fn from_descriptor_set(bytes: &[u8]) -> Result { - let set = FileDescriptorSet::decode(bytes) + let pool = DescriptorPool::decode(bytes) .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + Self::from_descriptor_pool(&pool) + } + + pub(crate) fn from_descriptor_pool(pool: &DescriptorPool) -> Result { + let service = pool + .get_service_by_name(SERVICE_OPEN_SHELL) + .ok_or_else(|| { + InterceptorError::Config(format!( + "descriptor set does not contain service '{SERVICE_OPEN_SHELL}'" + )) + })?; let mut all_methods = BTreeSet::new(); let mut unary_methods = BTreeSet::new(); let mut input_types = BTreeMap::new(); let mut output_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(); - let output_type = method - .output_type - .as_deref() - .unwrap_or_default() - .strip_prefix('.') - .unwrap_or_else(|| method.output_type.as_deref().unwrap_or_default()) - .to_string(); - unary_methods.insert(name.clone()); - input_types.insert(name.clone(), input_type); - output_types.insert(name, output_type); - } - } + for method in service.methods() { + let name = method.name().to_string(); + all_methods.insert(name.clone()); + if !method.is_client_streaming() && !method.is_server_streaming() { + unary_methods.insert(name.clone()); + input_types.insert(name.clone(), method.input().full_name().to_string()); + output_types.insert(name, method.output().full_name().to_string()); } } diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs new file mode 100644 index 0000000000..85fd504af0 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -0,0 +1,1199 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway request evaluation and interceptor execution. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Instant; + +use json_patch::{PatchOperation, patch}; +use metrics::{counter, histogram}; +use openshell_core::config::GatewayInterceptorConfig; +use openshell_core::proto::gateway_interceptor::v1::{ + InterceptorEvaluation, InterceptorResult, JsonPatch, ModifyOperationEvaluation, + PostCommitEvaluation, ValidateEvaluation, interceptor_evaluation, +}; +use prost::Message as _; +use prost_types::Struct; +use serde_json::{Map, Value}; +use tonic::{Code, Request, Status}; +use tracing::{info, warn}; + +use crate::plan::{BindingPlan, ExecutionPlan, FailurePolicy, Phase, RpcSelector}; +use crate::profile_source::GatewayInterceptorProfileSource; +use crate::{InterceptorError, ProtoJsonCodec, Result, routes}; + +const GRPC_HEADER_LEN: usize = 5; + +#[derive(Debug, Clone)] +pub struct GatewayInterceptorRuntime { + plan: Arc, + codec: ProtoJsonCodec, +} + +#[derive(Debug, Clone)] +pub struct EvaluationContext { + pub principal: BTreeMap, + pub validate_current_state: Option, +} + +#[derive(Debug, Clone)] +pub struct InterceptedRequest { + pub body: Vec, + selector: RpcSelector, +} + +#[derive(Debug, Clone, PartialEq)] +struct ValidatedOperation { + json: Value, + encoded: Vec, +} + +impl ValidatedOperation { + fn new(codec: &ProtoJsonCodec, type_name: &str, json: Value) -> Result { + let encoded = codec.encode_json_to_message(type_name, &json)?; + Ok(Self { json, encoded }) + } + + fn apply_patches( + &self, + codec: &ProtoJsonCodec, + type_name: &str, + patches: &[JsonPatch], + ) -> Result { + let json = apply_json_patches(&self.json, patches)?; + let encoded = codec + .encode_json_to_message(type_name, &json) + .map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + })?; + Ok(Self { json, encoded }) + } +} + +impl GatewayInterceptorRuntime { + pub(crate) async fn build(configs: Vec) -> Result { + let codec = ProtoJsonCodec::openshell()?; + let routes = routes::OpenShellRouteIndex::from_descriptor_pool(codec.descriptor_pool())?; + let plan = ExecutionPlan::load(configs, routes).await?; + Ok(Self { + plan: Arc::new(plan), + codec, + }) + } + + #[must_use] + pub fn provider_profile_source( + &self, + interceptor_name: &str, + ) -> Option { + self.plan.profile_source(interceptor_name) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.plan.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.plan.should_intercept(&selector) + } + + 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 + .plan + .input_type(&selector) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? + .to_string(); + let frame = GrpcFrame::decode(body)?; + let operation = self + .codec + .decode_bytes_to_json(&input_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let mut operation = ValidatedOperation::new(&self.codec, &input_type, operation) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + operation = self + .evaluate_phase( + &selector, + Phase::ModifyOperation, + &input_type, + operation, + context, + ) + .await?; + operation = self + .evaluate_phase(&selector, Phase::Validate, &input_type, operation, context) + .await?; + + let final_encoding = self + .codec + .encode_json_to_message(&input_type, &operation.json) + .map_err(|err| { + Status::internal(format!("validated operation became invalid: {err}")) + })?; + if final_encoding != operation.encoded { + return Err(Status::internal( + "validated operation encoding changed before dispatch", + )); + } + let body = GrpcFrame { + compressed: false, + message: operation.encoded, + } + .encode() + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + Ok(InterceptedRequest { body, selector }) + } + + pub async fn evaluate_post_commit( + &self, + intercepted: &InterceptedRequest, + response_body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result<(), Status> { + let output_type = self + .plan + .output_type(&intercepted.selector) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))?; + let frame = GrpcFrame::decode(response_body)?; + let committed_response = self + .codec + .decode_bytes_to_json(output_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let committed_response = + ValidatedOperation::new(&self.codec, output_type, committed_response) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + self.evaluate_phase( + &intercepted.selector, + Phase::PostCommit, + output_type, + committed_response, + context, + ) + .await + .map(|_| ()) + } + + #[must_use] + pub fn has_post_commit(&self, intercepted: &InterceptedRequest) -> bool { + self.plan + .has_binding(&intercepted.selector, Phase::PostCommit) + } + + async fn evaluate_phase( + &self, + selector: &RpcSelector, + phase: Phase, + operation_type: &str, + operation: ValidatedOperation, + context: &EvaluationContext, + ) -> std::result::Result { + let Some(plans) = self.plan.bindings(selector, phase) else { + return Ok(operation); + }; + + let mut operation = operation; + for plan in plans { + let result = evaluate_plan(plan, operation.json.clone(), context).await; + let result = match result { + Ok(result) => result, + Err(err) => { + apply_failure_policy(plan, &err)?; + continue; + } + }; + operation = + apply_evaluation_result(&self.codec, operation_type, plan, &result, operation)?; + } + Ok(operation) + } +} + +fn apply_evaluation_result( + codec: &ProtoJsonCodec, + operation_type: &str, + plan: &BindingPlan, + result: &InterceptorResult, + operation: ValidatedOperation, +) -> std::result::Result { + if let Err(err) = validate_result_contract(plan, result) { + apply_failure_policy(plan, &err)?; + return Ok(operation); + } + + 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); + emit_evaluation_log(plan, result, "deny", 0); + return Err(status_from_result(result, reason)); + } + + if plan.phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + match operation.apply_patches(codec, operation_type, &result.patches) { + Ok(candidate) => { + emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, result, "allow", patch_count); + Ok(candidate) + } + Err(err) => { + apply_failure_policy(plan, &err)?; + Ok(operation) + } + } + } else { + emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, result, "allow", 0); + Ok(operation) + } +} + +async fn evaluate_plan( + plan: &BindingPlan, + operation: Value, + context: &EvaluationContext, +) -> Result { + let operation = json_to_struct(operation)?; + let current_state = if plan.phase == Phase::Validate { + context + .validate_current_state + .clone() + .map(json_to_struct) + .transpose()? + } else { + None + }; + 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(), + principal: context.principal.clone().into_iter().collect(), + phase: Some(phase_evaluation(plan.phase, operation, current_state)), + }; + + 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 phase_evaluation( + phase: Phase, + proposed_operation: Struct, + current_state: Option, +) -> interceptor_evaluation::Phase { + match phase { + Phase::ModifyOperation => { + interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { + proposed_operation: Some(proposed_operation), + }) + } + Phase::Validate => interceptor_evaluation::Phase::Validate(ValidateEvaluation { + proposed_operation: Some(proposed_operation), + current_state, + }), + Phase::PostCommit => interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { + committed_response: Some(proposed_operation), + }), + } +} + +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(()) + } + } +} + +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(), + openshell_core::proto_struct::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 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", + "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", + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(patch_count as u64); + } +} + +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, + message: Vec, +} + +impl GrpcFrame { + fn decode(body: &[u8]) -> std::result::Result { + if body.len() < GRPC_HEADER_LEN { + return Err(Status::invalid_argument("gRPC frame is too short")); + } + let compressed = body[0] != 0; + if compressed { + return Err(Status::unimplemented( + "gateway interceptors do not support compressed gRPC frames", + )); + } + 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 body 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) + } +} + +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) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::gateway_interceptor::v1::gateway_interceptor_client::GatewayInterceptorClient; + use openshell_core::proto::{ + CreateSandboxRequest, SandboxSpec, SandboxTemplate, UpdateConfigRequest, + }; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }; + use tonic::transport::Channel; + use tracing_subscriber::layer::SubscriberExt; + + use crate::plan::{DEFAULT_MAX_PATCHES, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT}; + + #[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(()) + } + } + + #[derive(Debug)] + struct AtomicCounter(Arc); + + impl metrics::CounterFn for AtomicCounter { + fn increment(&self, value: u64) { + self.0.fetch_add(value, Ordering::Relaxed); + } + + fn absolute(&self, value: u64) { + self.0.fetch_max(value, Ordering::Relaxed); + } + } + + #[derive(Debug, Default)] + struct TestRecorder { + evaluations: Arc, + patches: Arc, + fail_open: Arc, + fail_closed: Arc, + } + + impl metrics::Recorder for TestRecorder { + fn describe_counter( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_gauge( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_histogram( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn register_counter( + &self, + key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Counter { + let counter = match key.name() { + "openshell_gateway_interceptor_evaluations_total" => &self.evaluations, + "openshell_gateway_interceptor_patches_total" => &self.patches, + "openshell_gateway_interceptor_fail_open_total" => &self.fail_open, + "openshell_gateway_interceptor_fail_closed_total" => &self.fail_closed, + _ => return metrics::Counter::noop(), + }; + metrics::Counter::from_arc(Arc::new(AtomicCounter(counter.clone()))) + } + + fn register_gauge( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Gauge { + metrics::Gauge::noop() + } + + fn register_histogram( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + metrics::Histogram::noop() + } + } + + impl TestRecorder { + fn count(counter: &AtomicU64) -> u64 { + counter.load(Ordering::Relaxed) + } + } + + fn test_modify_plan(failure_policy: FailurePolicy) -> BindingPlan { + BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "UpdateConfig".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy, + 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(), + ), + } + } + + fn patch(op: &str, path: &str, value: Value) -> JsonPatch { + JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_protobuf_value(value).unwrap()), + from: String::new(), + } + } + + fn allowed_result(patches: Vec) -> InterceptorResult { + InterceptorResult { + allowed: true, + patches, + ..InterceptorResult::default() + } + } + + #[test] + fn phase_evaluations_use_the_matching_oneof_variant() { + let operation = json_to_struct(json!({"name": "demo"})).unwrap(); + + let modify = phase_evaluation(Phase::ModifyOperation, operation.clone(), None); + let interceptor_evaluation::Phase::ModifyOperation(payload) = modify else { + panic!("expected modify_operation payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + + let validate = phase_evaluation(Phase::Validate, operation.clone(), None); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + assert!(payload.current_state.is_none()); + + let validate = + phase_evaluation(Phase::Validate, operation.clone(), Some(Struct::default())); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(Struct::default())); + + let current_state = json_to_struct(json!({"resourceVersion": "7"})).unwrap(); + let validate = phase_evaluation( + Phase::Validate, + operation.clone(), + Some(current_state.clone()), + ); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(current_state.clone())); + + let post_commit = + phase_evaluation(Phase::PostCommit, operation.clone(), Some(current_state)); + let interceptor_evaluation::Phase::PostCommit(payload) = post_commit else { + panic!("expected post_commit payload"); + }; + assert_eq!(payload.committed_response, Some(operation)); + } + + #[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")); + } + + #[tokio::test] + async fn request_middleware_canonicalizes_ambiguous_oneof_before_dispatch() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + .unwrap(); + let runtime = GatewayInterceptorRuntime { + plan: Arc::new(ExecutionPlan::empty(routes)), + codec: codec.clone(), + }; + let body = GrpcFrame { + compressed: false, + message: vec![0x3a, 0x04, 0x0a, 0x00, 0x1a, 0x00], + } + .encode() + .unwrap(); + + let intercepted = runtime + .evaluate_request( + "/openshell.v1.OpenShell/UpdateConfig", + &body, + &EvaluationContext { + principal: BTreeMap::new(), + validate_current_state: None, + }, + ) + .await + .unwrap(); + let frame = GrpcFrame::decode(&intercepted.body).unwrap(); + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &frame.message) + .unwrap(); + + assert_eq!(json["mergeOperations"][0], json!({"removeRule": {}})); + } + + #[test] + fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { + let codec = + ProtoJsonCodec::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 = codec + .decode_bytes_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 = codec + .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" })); + } + + #[test] + fn validated_operation_rejects_message_repeated_and_enum_type_errors() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let cases = [ + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {}}), + patch("replace", "/spec", json!("not-a-message")), + ), + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {"providers": []}}), + patch("replace", "/spec/providers", json!({"provider": "github"})), + ), + ( + "openshell.v1.ReportPolicyStatusRequest", + json!({ + "sandboxId": "sandbox-id", + "version": 1, + "status": "POLICY_STATUS_LOADED" + }), + patch("replace", "/status", json!("POLICY_STATUS_UNKNOWN")), + ), + ]; + + for (type_name, json, invalid_patch) in cases { + let operation = ValidatedOperation::new(&codec, type_name, json).unwrap(); + let err = operation + .apply_patches(&codec, type_name, &[invalid_patch]) + .expect_err("wrong-type patch must not produce a validated operation"); + + assert!(matches!(err, InterceptorError::InvalidResult(_))); + assert!(err.to_string().contains("patched operation is not valid")); + } + } + + #[tokio::test] + async fn wrong_type_patch_fails_open_to_exact_prior_operation_and_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let outcome = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(TestRecorder::count(&recorder.fail_open), 1); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn wrong_type_patch_fails_closed_before_dispatch_and_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailClosed); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let status = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .expect_err("fail-closed invalid candidate must stop dispatch"); + + assert_eq!(status.code(), Code::PermissionDenied); + assert!(status.message().contains("patched operation is not valid")); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 1); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn invalid_patch_list_is_atomic() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let result = allowed_result(vec![ + patch("replace", "/name", json!("partially-mutated")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation, + ) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(outcome.json["name"], "demo"); + } + + #[tokio::test] + async fn later_binding_sees_pre_binding_value_after_invalid_candidate_fails_open() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let invalid_first = allowed_result(vec![ + patch("replace", "/name", json!("rejected-candidate")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + let second = allowed_result(vec![ + patch("test", "/name", json!("demo")), + patch("replace", "/name", json!("accepted-candidate")), + ]); + + let operation = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &invalid_first, + operation, + ) + .unwrap(); + let operation = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &second, + operation, + ) + .unwrap(); + + assert_eq!(operation.json["name"], "accepted-candidate"); + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted-candidate"); + assert_eq!(decoded.expected_resource_version, 7); + } + + #[tokio::test] + async fn ambiguous_oneof_patch_obeys_binding_failure_policy() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.PolicyMergeOperation", + json!({"addRule": {}}), + ) + .unwrap(); + let result = allowed_result(vec![patch("add", "/removeRule", json!({}))]); + + let fail_open = apply_evaluation_result( + &codec, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation.clone(), + ) + .unwrap(); + let fail_closed = apply_evaluation_result( + &codec, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation.clone(), + ) + .expect_err("ambiguous oneof candidate must fail closed"); + + assert_eq!(fail_open, operation); + assert_eq!(fail_closed.code(), Code::PermissionDenied); + assert!(fail_closed.message().contains("oneof 'operation'")); + } + + #[tokio::test] + async fn accepted_candidate_updates_encoded_operation_and_patch_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let result = allowed_result(vec![patch("replace", "/name", json!("accepted"))]); + let recorder = TestRecorder::default(); + + let operation = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + }) + .unwrap(); + + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted"); + assert_eq!(TestRecorder::count(&recorder.evaluations), 1); + assert_eq!(TestRecorder::count(&recorder.patches), 1); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + } +} diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index ab85d179e1..0922dcb63f 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -906,12 +906,12 @@ dependencies = [ name = "openshell-gateway-interceptors" version = "0.0.0" dependencies = [ - "base64", "hyper-util", "json-patch", "metrics", "openshell-core", "prost", + "prost-reflect", "prost-types", "serde_json", "sha2", @@ -972,6 +972,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "owo-colors" version = "4.3.0" @@ -1138,6 +1147,19 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "base64", + "prost", + "prost-types", + "serde", + "serde-value", +] + [[package]] name = "prost-types" version = "0.14.4" @@ -1390,6 +1412,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" From 5c86edb504fbf4e4ae13d077345722fbd8b3671a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 10:28:50 -0700 Subject: [PATCH 20/27] fix(governance-example): canonicalize signed protobuf hashes Signed-off-by: Drew Newberry --- examples/governance-interceptor/Cargo.lock | 86 +---- examples/governance-interceptor/Cargo.toml | 2 +- examples/governance-interceptor/README.md | 8 + examples/governance-interceptor/src/main.rs | 56 +-- .../governance-interceptor/src/policy_hash.rs | 327 +++++++++++++++--- .../governance-interceptor/src/proto_json.rs | 85 +++++ examples/governance-interceptor/src/tests.rs | 77 ++++- 7 files changed, 481 insertions(+), 160 deletions(-) create mode 100644 examples/governance-interceptor/src/proto_json.rs diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 0922dcb63f..a3df3b79f4 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -679,17 +679,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json-patch" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9ad60d674508f3ca8f380a928cfe7b096bc729c4e2dbfe3852bc45da3ab30b" -dependencies = [ - "serde", - "serde_json", - "thiserror 1.0.69", -] - [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -760,16 +749,6 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "metrics" -version = "0.24.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" -dependencies = [ - "portable-atomic", - "rapidhash", -] - [[package]] name = "miette" version = "7.6.0" @@ -893,7 +872,7 @@ dependencies = [ "protobuf-src", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tonic", "tonic-prost", @@ -902,36 +881,16 @@ dependencies = [ "url", ] -[[package]] -name = "openshell-gateway-interceptors" -version = "0.0.0" -dependencies = [ - "hyper-util", - "json-patch", - "metrics", - "openshell-core", - "prost", - "prost-reflect", - "prost-types", - "serde_json", - "sha2", - "thiserror 2.0.18", - "tokio", - "tonic", - "tower", - "tracing", -] - [[package]] name = "openshell-governance-interceptor-example" version = "0.0.0" dependencies = [ "jsonwebtoken", "openshell-core", - "openshell-gateway-interceptors", "openshell-policy", "openshell-providers", "prost", + "prost-reflect", "prost-types", "rcgen", "serde", @@ -962,7 +921,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", - "thiserror 2.0.18", + "thiserror", "url", ] @@ -1063,12 +1022,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1213,15 +1166,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rapidhash" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" -dependencies = [ - "rustversion", -] - [[package]] name = "rcgen" version = "0.13.2" @@ -1505,7 +1449,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -1625,33 +1569,13 @@ dependencies = [ "unicode-width 0.2.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index d38b412c88..d0ddeb2e8c 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -13,10 +13,10 @@ license = "Apache-2.0" [dependencies] jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } -openshell-gateway-interceptors = { path = "../../crates/openshell-gateway-interceptors" } openshell-policy = { path = "../../crates/openshell-policy" } openshell-providers = { path = "../../crates/openshell-providers" } prost = "0.14" +prost-reflect = { version = "0.16.5", features = ["serde"] } prost-types = "0.14" rcgen = { version = "0.13", features = ["crypto", "pem"] } serde = { version = "1", features = ["derive"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 887749e94a..a8e163e5ca 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -41,6 +41,14 @@ start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. +The example owns this digest contract independently of the gateway. It uses a +local reflected protobuf codec, recursively sorts ProtoJSON object keys, and +preserves repeated-field order. Policy and profile hashes use the +`sha256:v2:` format, and their JWTs require +`hash_algorithm=openshell-governance-protojson-sha256-v2`. The gateway's policy +hash is a separate operational revision identifier and is not expected to +match the signed governance hash. + The interceptor polls the policy file every second by default. When `policy.yaml` changes and parses successfully, the interceptor re-signs it immediately. New sandboxes receive the updated signed policy through `CreateSandbox`. If diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 39f9528e75..83930ceb7f 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 mod policy_hash; +mod proto_json; use std::collections::HashMap; use std::net::SocketAddr; @@ -23,13 +24,16 @@ use openshell_core::proto::{ ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, UpdateConfigRequest, open_shell_client::OpenShellClient, }; -use openshell_gateway_interceptors::ProtoJsonCodec; use openshell_policy::parse_sandbox_policy; use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; -use policy_hash::deterministic_policy_hash; +use policy_hash::{ + HASH_ALGORITHM, canonical_policy_hash, canonical_profile_hash, + canonical_profile_snapshot_revision, is_v2_digest, +}; use prost::Message as _; use prost_types::ListValue; use prost_types::{Struct, Value as ProtoValue, value::Kind}; +use proto_json::{decode_message_to_json, encode_json_to_message}; use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value, json}; @@ -79,6 +83,7 @@ struct PolicySignatureClaims { aud: String, iat: i64, exp: i64, + hash_algorithm: String, policy_sha256: String, } @@ -89,6 +94,7 @@ struct ProfileSignatureClaims { aud: String, iat: i64, exp: i64, + hash_algorithm: String, profile_id: String, profile_sha256: String, } @@ -122,6 +128,7 @@ impl PolicySigner { aud: POLICY_JWT_AUDIENCE.to_string(), iat: now_secs(), exp: 0, + hash_algorithm: HASH_ALGORITHM.to_string(), policy_sha256: policy_hash.to_string(), }; let mut header = Header::new(Algorithm::EdDSA); @@ -137,6 +144,7 @@ impl PolicySigner { aud: PROFILE_JWT_AUDIENCE.to_string(), iat: 0, exp: 0, + hash_algorithm: HASH_ALGORITHM.to_string(), profile_id: profile_id.to_string(), profile_sha256: profile_hash.to_string(), }; @@ -168,6 +176,12 @@ impl PolicySigner { if data.claims.sub != POLICY_JWT_SUBJECT { return Err("unexpected policy JWT subject".to_string()); } + if data.claims.hash_algorithm != HASH_ALGORITHM { + return Err("unexpected policy hash algorithm".to_string()); + } + if !is_v2_digest(&data.claims.policy_sha256) { + return Err("unexpected policy hash format".to_string()); + } if data.claims.policy_sha256 != policy_hash { return Err("signed policy hash does not match sandbox policy".to_string()); } @@ -205,6 +219,12 @@ impl PolicySigner { if data.claims.profile_id != profile_id { return Err("unexpected provider profile id".to_string()); } + if data.claims.hash_algorithm != HASH_ALGORITHM { + return Err("unexpected provider profile hash algorithm".to_string()); + } + if !is_v2_digest(&data.claims.profile_sha256) { + return Err("unexpected provider profile hash format".to_string()); + } if data.claims.profile_sha256 != profile_hash { return Err("signed provider profile hash does not match profile".to_string()); } @@ -631,7 +651,7 @@ fn validate_signed_policy_payload( policy_signer: &PolicySigner, ) -> Result<(), String> { let sandbox_policy = sandbox_policy_from_interceptor_json(policy)?; - let sandbox_policy_hash = deterministic_policy_hash(&sandbox_policy); + let sandbox_policy_hash = canonical_policy_hash(&sandbox_policy)?; policy_signer .verify_policy_signature(signature, &sandbox_policy_hash) .map_err(|err| format!("sandbox policy signature is invalid: {err}"))?; @@ -726,7 +746,7 @@ fn load_policy_state( .map_err(|err| format!("failed to parse policy YAML: {err}"))?; let policy = sandbox_policy_to_proto_json(&policy_proto)?; let policy = normalize_for_struct(policy)?; - let policy_hash = deterministic_policy_hash(&policy_proto); + let policy_hash = canonical_policy_hash(&policy_proto)?; let policy_signature = policy_signer.sign_policy(&policy_hash)?; Ok(PolicyState { policy, @@ -876,7 +896,7 @@ fn profile_state_from_loaded( .map(|loaded| sign_provider_profile(loaded.profile, policy_signer)) .collect::, _>>()?; Ok(ProviderProfileState { - revision: provider_profile_revision(&profiles), + revision: canonical_profile_snapshot_revision(&profiles)?, ids, profiles, }) @@ -890,7 +910,7 @@ fn sign_provider_profile( profile.annotations.remove(PROFILE_HASH_ANNOTATION); profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); - let profile_hash = deterministic_profile_hash(&profile); + let profile_hash = deterministic_profile_hash(&profile)?; let profile_signature = policy_signer.sign_profile(&profile.id, &profile_hash)?; profile .annotations @@ -905,26 +925,12 @@ fn sign_provider_profile( Ok(profile) } -fn provider_profile_revision(profiles: &[ProviderProfile]) -> String { - let mut profiles = profiles.to_vec(); - profiles.sort_by(|left, right| left.id.cmp(&right.id)); - let mut hasher = Sha256::new(); - hasher.update(b"openshell-governance-provider-profiles-v1"); - for profile in profiles { - hasher.update(profile.encode_to_vec()); - } - format!("sha256:{:x}", hasher.finalize()) -} - -fn deterministic_profile_hash(profile: &ProviderProfile) -> String { +fn deterministic_profile_hash(profile: &ProviderProfile) -> Result { let mut profile = profile.clone(); profile.annotations.remove(PROFILE_SIGNATURE_ANNOTATION); profile.annotations.remove(PROFILE_HASH_ANNOTATION); profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); - let mut hasher = Sha256::new(); - hasher.update(b"openshell-governance-provider-profile-v1"); - hasher.update(profile.encode_to_vec()); - format!("sha256:{:x}", hasher.finalize()) + canonical_profile_hash(&profile) } fn is_managed_profile_id(managed_profile_ids: &[String], id: &str) -> bool { @@ -1021,14 +1027,12 @@ fn now_secs() -> i64 { } fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Result { - ProtoJsonCodec::openshell() - .and_then(|codec| codec.decode_message_to_json(SANDBOX_POLICY_TYPE, policy)) + decode_message_to_json(SANDBOX_POLICY_TYPE, policy) .map_err(|err| format!("failed to render policy protobuf JSON: {err}")) } fn sandbox_policy_from_interceptor_json(policy: &Value) -> Result { - let bytes = ProtoJsonCodec::openshell() - .and_then(|codec| codec.encode_json_to_message(SANDBOX_POLICY_TYPE, policy)) + let bytes = encode_json_to_message(SANDBOX_POLICY_TYPE, policy) .map_err(|err| format!("sandbox policy cannot be decoded as protobuf JSON: {err}"))?; SandboxPolicy::decode(bytes.as_slice()) .map_err(|err| format!("sandbox policy protobuf payload is invalid: {err}")) diff --git a/examples/governance-interceptor/src/policy_hash.rs b/examples/governance-interceptor/src/policy_hash.rs index 18a45bb9f0..7344d5209e 100644 --- a/examples/governance-interceptor/src/policy_hash.rs +++ b/examples/governance-interceptor/src/policy_hash.rs @@ -1,34 +1,130 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use openshell_core::proto::SandboxPolicy; -use prost::Message as _; +use openshell_core::proto::{ProviderProfile, SandboxPolicy}; +use prost::Message; +use serde_json::Value; use sha2::{Digest, Sha256}; -/// Compute a deterministic SHA-256 hash of a sandbox policy. -/// -/// Protobuf binary encoding is not canonical for maps, so this hashes scalar -/// fields directly and sorts `network_policies` by key before hashing each -/// encoded rule. -pub(crate) fn deterministic_policy_hash(policy: &SandboxPolicy) -> String { +use crate::proto_json::decode_message_to_json; + +pub(crate) const HASH_ALGORITHM: &str = "openshell-governance-protojson-sha256-v2"; +const HASH_PREFIX: &str = "sha256:v2:"; +const SANDBOX_POLICY_TYPE: &str = "openshell.sandbox.v1.SandboxPolicy"; +const PROVIDER_PROFILE_TYPE: &str = "openshell.v1.ProviderProfile"; +const POLICY_DOMAIN: &str = "openshell-governance-policy"; +const PROFILE_DOMAIN: &str = "openshell-governance-provider-profile"; +const PROFILE_SNAPSHOT_DOMAIN: &str = "openshell-governance-provider-profile-snapshot"; + +pub(crate) fn canonical_policy_hash(policy: &SandboxPolicy) -> Result { + canonical_message_hash(SANDBOX_POLICY_TYPE, policy, POLICY_DOMAIN) +} + +pub(crate) fn canonical_profile_hash(profile: &ProviderProfile) -> Result { + canonical_message_hash(PROVIDER_PROFILE_TYPE, profile, PROFILE_DOMAIN) +} + +pub(crate) fn canonical_profile_snapshot_revision( + profiles: &[ProviderProfile], +) -> Result { + let mut canonical_profiles = profiles + .iter() + .map(|profile| { + Ok(( + profile.id.as_bytes(), + canonical_message_bytes(PROVIDER_PROFILE_TYPE, profile)?, + )) + }) + .collect::, String>>()?; + canonical_profiles.sort_by(|left, right| left.0.cmp(right.0)); + let mut hasher = Sha256::new(); - hasher.update(policy.version.to_le_bytes()); - if let Some(filesystem) = &policy.filesystem { - hasher.update(filesystem.encode_to_vec()); + hasher.update(HASH_ALGORITHM.as_bytes()); + hash_framed(&mut hasher, PROFILE_SNAPSHOT_DOMAIN.as_bytes())?; + hash_framed(&mut hasher, PROVIDER_PROFILE_TYPE.as_bytes())?; + hash_framed( + &mut hasher, + &u64::try_from(canonical_profiles.len()) + .map_err(|_| "provider profile count exceeds hash framing limit")? + .to_be_bytes(), + )?; + for (id, canonical) in canonical_profiles { + hash_framed(&mut hasher, id)?; + hash_framed(&mut hasher, &canonical)?; } - if let Some(landlock) = &policy.landlock { - hasher.update(landlock.encode_to_vec()); - } - if let Some(process) = &policy.process { - hasher.update(process.encode_to_vec()); - } - let mut entries: Vec<_> = policy.network_policies.iter().collect(); - entries.sort_by_key(|(key, _)| key.as_str()); - for (key, value) in entries { - hasher.update(key.as_bytes()); - hasher.update(value.encode_to_vec()); + Ok(format!("{HASH_PREFIX}{}", hex_encode(&hasher.finalize()))) +} + +pub(crate) fn is_v2_digest(value: &str) -> bool { + value.strip_prefix(HASH_PREFIX).is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn canonical_message_hash(type_name: &str, message: &M, domain: &str) -> Result +where + M: Message, +{ + let canonical = canonical_message_bytes(type_name, message)?; + let mut hasher = Sha256::new(); + hasher.update(HASH_ALGORITHM.as_bytes()); + hash_framed(&mut hasher, domain.as_bytes())?; + hash_framed(&mut hasher, type_name.as_bytes())?; + hash_framed(&mut hasher, &canonical)?; + Ok(format!("{HASH_PREFIX}{}", hex_encode(&hasher.finalize()))) +} + +fn canonical_message_bytes(type_name: &str, message: &M) -> Result, String> +where + M: Message, +{ + let value = decode_message_to_json(type_name, message)?; + let mut bytes = Vec::new(); + write_canonical_json(&value, &mut bytes)?; + Ok(bytes) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) -> Result<(), String> { + match value { + Value::Object(object) => { + output.push(b'{'); + let mut keys = object.keys().collect::>(); + keys.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + for (index, key) in keys.into_iter().enumerate() { + if index != 0 { + output.push(b','); + } + serde_json::to_writer(&mut *output, key) + .map_err(|err| format!("serialize canonical JSON object key: {err}"))?; + output.push(b':'); + write_canonical_json(&object[key], output)?; + } + output.push(b'}'); + } + Value::Array(values) => { + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + output.push(b','); + } + write_canonical_json(value, output)?; + } + output.push(b']'); + } + _ => serde_json::to_writer(output, value) + .map_err(|err| format!("serialize canonical JSON scalar: {err}"))?, } - hex_encode(&hasher.finalize()) + Ok(()) +} + +fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), String> { + let length = u64::try_from(bytes.len()).map_err(|_| "hash input exceeds framing limit")?; + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(()) } fn hex_encode(bytes: &[u8]) -> String { @@ -43,49 +139,135 @@ fn hex_encode(bytes: &[u8]) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use openshell_core::proto::{ + GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkEndpoint, + NetworkPolicyRule, + }; + use super::*; - use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; #[test] - fn sorts_network_policy_map_keys() { - let left = policy_with_network_rules(&[ - ("beta", "beta.example.com"), - ("alpha", "alpha.example.com"), - ]); - let right = policy_with_network_rules(&[ - ("alpha", "alpha.example.com"), - ("beta", "beta.example.com"), - ]); + fn policy_hash_is_recursive_and_preserves_repeated_order() { + let left = policy_with_nested_maps(false); + let right = policy_with_nested_maps(true); assert_eq!( - deterministic_policy_hash(&left), - deterministic_policy_hash(&right) + canonical_policy_hash(&left).unwrap(), + canonical_policy_hash(&right).unwrap() ); - let changed = policy_with_network_rules(&[ - ("alpha", "alpha.example.com"), - ("beta", "changed.example.com"), - ]); + let mut reordered = right; + reordered.network_policies.get_mut("api").unwrap().endpoints[0].rules[0] + .allow + .as_mut() + .unwrap() + .fields + .reverse(); assert_ne!( - deterministic_policy_hash(&left), - deterministic_policy_hash(&changed) + canonical_policy_hash(&left).unwrap(), + canonical_policy_hash(&reordered).unwrap() ); } - fn policy_with_network_rules(rules: &[(&str, &str)]) -> SandboxPolicy { + #[test] + fn profile_hash_and_snapshot_revision_ignore_map_and_profile_order() { + let left_profile = ProviderProfile { + id: "alpha".to_string(), + annotations: map(false, [("zeta", "last"), ("alpha", "first")]), + endpoints: vec![ + policy_with_nested_maps(false) + .network_policies + .remove("api") + .unwrap() + .endpoints + .remove(0), + ], + ..ProviderProfile::default() + }; + let right_profile = ProviderProfile { + id: "alpha".to_string(), + annotations: map(true, [("zeta", "last"), ("alpha", "first")]), + endpoints: vec![ + policy_with_nested_maps(true) + .network_policies + .remove("api") + .unwrap() + .endpoints + .remove(0), + ], + ..ProviderProfile::default() + }; + assert_eq!( + canonical_profile_hash(&left_profile).unwrap(), + canonical_profile_hash(&right_profile).unwrap() + ); + + let beta = ProviderProfile { + id: "beta".to_string(), + ..ProviderProfile::default() + }; + assert_eq!( + canonical_profile_snapshot_revision(&[left_profile, beta.clone()]).unwrap(), + canonical_profile_snapshot_revision(&[beta, right_profile]).unwrap() + ); + } + + #[test] + fn digest_format_is_explicitly_v2() { + let digest = canonical_policy_hash(&SandboxPolicy::default()).unwrap(); + assert!(is_v2_digest(&digest)); + assert!(!is_v2_digest("sha256:deadbeef")); + assert!(!is_v2_digest(&format!("{HASH_PREFIX}{}", "A".repeat(64)))); + } + + fn policy_with_nested_maps(reverse: bool) -> SandboxPolicy { + let allow = L7Allow { + query: map(reverse, [("state", "open"), ("label", "bug")]), + params: map(reverse, [("name", "search"), ("kind", "tool")]), + fields: vec!["repository".to_string(), "issues".to_string()], + ..L7Allow::default() + }; + let deny = L7DenyRule { + query: map(reverse, [("private", "true"), ("admin", "true")]), + params: map(reverse, [("name", "delete"), ("kind", "tool")]), + ..L7DenyRule::default() + }; + let endpoint = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + rules: vec![L7Rule { allow: Some(allow) }], + deny_rules: vec![deny], + graphql_persisted_queries: map( + reverse, + [("query-b", "LookupB"), ("query-a", "LookupA")], + ), + ..NetworkEndpoint::default() + }; + let rules = if reverse { + [("unused", "unused.example.com"), ("api", "api.example.com")] + } else { + [("api", "api.example.com"), ("unused", "unused.example.com")] + }; SandboxPolicy { version: 1, network_policies: rules - .iter() - .map(|(key, host)| { + .into_iter() + .map(|(name, host)| { + let endpoints = if name == "api" { + vec![endpoint.clone()] + } else { + vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..NetworkEndpoint::default() + }] + }; ( - (*key).to_string(), + name.to_string(), NetworkPolicyRule { - name: (*key).to_string(), - endpoints: vec![NetworkEndpoint { - host: (*host).to_string(), - port: 443, - ..NetworkEndpoint::default() - }], + name: name.to_string(), + endpoints, ..NetworkPolicyRule::default() }, ) @@ -94,4 +276,47 @@ mod tests { ..SandboxPolicy::default() } } + + trait MapValue: Sized { + fn from_test_value(value: &str) -> Self; + } + + impl MapValue for String { + fn from_test_value(value: &str) -> Self { + value.to_string() + } + } + + impl MapValue for L7QueryMatcher { + fn from_test_value(value: &str) -> Self { + Self { + glob: value.to_string(), + ..Self::default() + } + } + } + + impl MapValue for GraphqlOperation { + fn from_test_value(value: &str) -> Self { + Self { + operation_type: "query".to_string(), + operation_name: value.to_string(), + fields: vec![value.to_string()], + } + } + } + + fn map(reverse: bool, entries: [(&str, &str); N]) -> HashMap + where + V: MapValue, + { + let mut entries = entries; + if reverse { + entries.reverse(); + } + entries + .into_iter() + .map(|(key, value)| (key.to_string(), V::from_test_value(value))) + .collect() + } } diff --git a/examples/governance-interceptor/src/proto_json.rs b/examples/governance-interceptor/src/proto_json.rs new file mode 100644 index 0000000000..5dcf31087a --- /dev/null +++ b/examples/governance-interceptor/src/proto_json.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Example-local descriptor-backed protobuf/ProtoJSON conversion. + +use std::sync::{Arc, OnceLock}; + +use prost::Message as _; +use prost_reflect::{DescriptorPool, DeserializeOptions, DynamicMessage, MessageDescriptor}; +use serde_json::Value; + +#[derive(Debug, Clone)] +struct ProtoJsonCodec { + pool: Arc, +} + +impl ProtoJsonCodec { + fn openshell() -> Result { + let pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .map_err(|err| format!("decode OpenShell protobuf descriptor set: {err}"))?; + Ok(Self { + pool: Arc::new(pool), + }) + } + + fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: prost::Message, + { + let descriptor = self.message_descriptor(type_name)?; + let message = DynamicMessage::decode(descriptor, message.encode_to_vec().as_slice()) + .map_err(|err| format!("decode {type_name} protobuf message: {err}"))?; + serde_json::to_value(message) + .map_err(|err| format!("serialize {type_name} as ProtoJSON: {err}")) + } + + fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result, String> { + let descriptor = self.message_descriptor(type_name)?; + let encoded_json = serde_json::to_vec(value) + .map_err(|err| format!("serialize {type_name} JSON before protobuf encoding: {err}"))?; + let mut deserializer = serde_json::Deserializer::from_slice(&encoded_json); + let message = DynamicMessage::deserialize_with_options( + descriptor, + &mut deserializer, + &DeserializeOptions::new(), + ) + .map_err(|err| format!("encode {type_name} from ProtoJSON: {err}"))?; + deserializer + .end() + .map_err(|err| format!("encode {type_name} from ProtoJSON: {err}"))?; + Ok(message.encode_to_vec()) + } + + fn message_descriptor(&self, type_name: &str) -> Result { + let normalized = type_name.strip_prefix('.').unwrap_or(type_name); + self.pool.get_message_by_name(normalized).ok_or_else(|| { + format!("protobuf message type '{normalized}' was not found in the descriptor set") + }) + } +} + +fn openshell_codec() -> Result<&'static ProtoJsonCodec, String> { + static CODEC: OnceLock = OnceLock::new(); + + if let Some(codec) = CODEC.get() { + return Ok(codec); + } + + let codec = ProtoJsonCodec::openshell()?; + let _ = CODEC.set(codec); + CODEC + .get() + .ok_or_else(|| "initialize OpenShell protobuf codec".to_string()) +} + +pub(crate) fn decode_message_to_json(type_name: &str, message: &M) -> Result +where + M: prost::Message, +{ + openshell_codec()?.decode_message_to_json(type_name, message) +} + +pub(crate) fn encode_json_to_message(type_name: &str, value: &Value) -> Result, String> { + openshell_codec()?.encode_json_to_message(type_name, value) +} diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index ade8baed88..d126b62b5c 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -55,6 +55,12 @@ fn policy_state(service: &GovernanceInterceptorService) -> PolicyState { service.current_policy_state() } +fn jwt_header(service: &GovernanceInterceptorService) -> Header { + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(service.policy_signer.kid().to_string()); + header +} + #[test] fn evaluation_requires_a_phase_payload() { let service = service(); @@ -93,7 +99,10 @@ fn assert_signed_profile(service: &GovernanceInterceptorService, profile: &Provi .annotations .get(PROFILE_HASH_ANNOTATION) .expect("profile hash annotation"); - assert_eq!(profile_hash, &deterministic_profile_hash(profile)); + assert_eq!( + profile_hash, + &deterministic_profile_hash(profile).expect("profile hash") + ); assert_eq!( profile .annotations @@ -450,6 +459,72 @@ fn create_sandbox_validate_denies_signed_policy_mismatch() { assert!(result.reason.contains("signature")); } +#[test] +fn policy_signature_rejects_legacy_hash_algorithm() { + let service = service(); + let state = policy_state(&service); + 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, + hash_algorithm: "openshell-governance-protobuf-sha256-v1".to_string(), + policy_sha256: state.policy_hash.clone(), + }; + let token = encode( + &jwt_header(&service), + &claims, + &service.policy_signer.encoding_key, + ) + .unwrap(); + + let error = service + .policy_signer + .verify_policy_signature(&token, &state.policy_hash) + .unwrap_err(); + assert_eq!(error, "unexpected policy hash algorithm"); +} + +#[test] +fn profile_signature_rejects_missing_hash_algorithm() { + #[derive(serde::Serialize)] + struct LegacyProfileSignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + profile_id: String, + profile_sha256: String, + } + + let service = service(); + let profile = &service.current_profile_state().profiles[0]; + let profile_hash = profile.annotations.get(PROFILE_HASH_ANNOTATION).unwrap(); + let claims = LegacyProfileSignatureClaims { + sub: format!("{PROFILE_JWT_SUBJECT_PREFIX}{}", profile.id), + iss: POLICY_JWT_ISSUER.to_string(), + aud: PROFILE_JWT_AUDIENCE.to_string(), + iat: 0, + exp: 0, + profile_id: profile.id.clone(), + profile_sha256: profile_hash.clone(), + }; + let token = encode( + &jwt_header(&service), + &claims, + &service.policy_signer.encoding_key, + ) + .unwrap(); + + let error = service + .policy_signer + .verify_profile_signature(&token, &profile.id, profile_hash) + .unwrap_err(); + assert!(error.contains("missing field `hash_algorithm`")); +} + #[test] fn policy_patch_uses_protobuf_json_names() { let service = service(); From 46d65c7b4dfc8d58415b7674ba99203e72524181 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 11:26:12 -0700 Subject: [PATCH 21/27] fix(gateway): commit policy provenance atomically Signed-off-by: Drew Newberry --- architecture/gateway.md | 9 + crates/openshell-cli/src/run.rs | 40 +- crates/openshell-server/src/grpc/policy.rs | 461 ++++++++++++++---- .../src/persistence/postgres.rs | 107 +++- .../src/persistence/sqlite.rs | 110 ++++- .../openshell-server/src/persistence/tests.rs | 134 ++++- crates/openshell-server/src/policy_store.rs | 139 +++++- proto/openshell.proto | 11 +- 8 files changed, 873 insertions(+), 138 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 581ee83e6b..cd1689b050 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -198,6 +198,15 @@ populate `scope`, `version`, `status`, `dedup_key`, and `hit_count` so the gateway can efficiently fetch the latest policy, track load status, and manage advisor drafts without creating resource-specific tables. +Each sandbox policy revision stores the complete provenance annotation map +supplied with that update. The revision payload is the authoritative immutable +record; sandbox metadata receives the same annotations only as a convenience +projection and can retain keys from earlier revisions. Policy revision creation, +optional first-policy backfill, metadata projection, and superseding older +revisions commit in one database transaction. SQLite serializes this operation +with an immediate transaction, while Postgres locks the sandbox row. A failed +resource-version check or revision insert rolls back the entire operation. + SQLite is the default local store; Postgres is supported for deployments that need an external database or multi-replica coordination. Both backends expose the same `Store` API and the same logical schema. Backend differences stay diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f855b0ec66..5880e21a71 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -7329,6 +7329,9 @@ fn policy_revision_to_json( if !rev.load_error.is_empty() { obj.insert("load_error".to_string(), serde_json::json!(rev.load_error)); } + if !rev.provenance.is_empty() { + obj.insert("provenance".to_string(), serde_json::json!(rev.provenance)); + } if view.includes_policy() { let policy = match rev.policy.as_ref() { Some(policy) => { @@ -7884,7 +7887,7 @@ fn format_timestamp_ms(ms: i64) -> String { #[cfg(test)] mod tests { use super::{ - ProvisioningStep, TlsOptions, build_sandbox_resource_limits, + PolicyGetView, ProvisioningStep, TlsOptions, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, format_gateway_select_header, format_gateway_select_items, format_provider_attachment_table, gateway_add, gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_to_json, @@ -7892,7 +7895,7 @@ mod tests { inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, - plaintext_gateway_is_remote, progress_step_from_metadata, + plaintext_gateway_is_remote, policy_revision_to_json, progress_step_from_metadata, provider_profile_allows_empty_credentials, provisioning_timeout_message, ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, @@ -7917,12 +7920,41 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GpuResourceRequirements, Provider, ProviderCredentialRefresh, + GpuResourceRequirements, PolicyStatus, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, - ResourceRequirements, SandboxCondition, SandboxStatus, datamodel::v1::ObjectMeta, + ResourceRequirements, SandboxCondition, SandboxPolicyRevision, SandboxStatus, + datamodel::v1::ObjectMeta, }; + #[test] + fn policy_revision_json_includes_revision_provenance() { + let revision = SandboxPolicyRevision { + version: 2, + policy_hash: "hash".to_string(), + provenance: std::collections::HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + + let json = policy_revision_to_json( + "sandbox", + Some("example"), + Some(2), + &revision, + PolicyStatus::Pending, + PolicyGetView::Metadata, + ) + .unwrap(); + + assert_eq!( + json["provenance"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + } + struct EnvVarGuard { key: &'static str, original: Option, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 5b04ed4972..80cd995b29 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -13,7 +13,7 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; -use crate::policy_store::PolicyStoreExt; +use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; use crate::provider_profile_sources::ProviderProfileSources; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; @@ -1127,6 +1127,42 @@ async fn persist_update_config_annotations( Ok(sandbox_metadata_annotations(&updated)) } +async fn persist_existing_policy_projection( + state: &Arc, + sandbox_id: &str, + expected_resource_version: u64, + annotations: &HashMap, + current_annotations: &HashMap, + backfill_policy: Option<&ProtoSandboxPolicy>, +) -> Result, Status> { + let annotations_match = annotations + .iter() + .all(|(key, value)| current_annotations.get(key) == Some(value)); + if backfill_policy.is_none() && annotations_match { + return Ok(current_annotations.clone()); + } + + let annotations = annotations.clone(); + let backfill_policy = backfill_policy.cloned(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + if let Some(policy) = backfill_policy.as_ref() + && let Some(spec) = sandbox.spec.as_mut() + && spec.policy.is_none() + { + spec.policy = Some(policy.clone()); + } + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.annotations.extend(annotations.clone()); + } + }) + .await + .map_err(|e| super::persistence_error_to_status(e, "store policy projection"))?; + + Ok(sandbox_metadata_annotations(&updated)) +} + async fn resolve_sandbox_by_name_for_principal( store: &Store, principal: &Principal, @@ -1866,21 +1902,32 @@ async fn handle_update_config_inner( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let merge_ops = parse_merge_operations(&req.merge_operations)?; validate_merge_operations_for_server(&merge_ops)?; - let (version, hash) = apply_merge_operations_with_retry( + let atomic_context = AtomicPolicyWriteContext { + expected_resource_version: req.expected_resource_version, + provenance: &req.annotations, + annotations: &req.annotations, + }; + let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, spec.policy.as_ref(), &merge_ops, + Some(&atomic_context), ) .await?; - response_annotations = persist_update_config_annotations( - state, - &sandbox_id, - req.expected_resource_version, - &req.annotations, - &response_annotations, - ) - .await?; + response_annotations = if let Some(updated_sandbox) = updated_sandbox { + sandbox_metadata_annotations(&updated_sandbox) + } else { + persist_existing_policy_projection( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + None, + ) + .await? + }; state.sandbox_watch_bus.notify(&sandbox_id); emit_gateway_policy_audit_log( @@ -1954,94 +2001,108 @@ async fn handle_update_config_inner( validate_no_reserved_provider_policy_keys(&new_policy)?; } - if let Some(baseline_policy) = spec.policy.as_ref() { + let backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { validate_static_fields_unchanged(baseline_policy, &new_policy)?; validate_policy_safety(&new_policy)?; + None } else { - // Backfill spec.policy using CAS (first-time policy discovery) - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox_id = sandbox.object_id().to_string(); - let new_policy_clone = new_policy.clone(); - let annotations = req.annotations.clone(); - let updated_sandbox = state - .store - .update_message_cas::( - &sandbox_id, - req.expected_resource_version, - |sandbox| { - if let Some(ref mut spec) = sandbox.spec - && spec.policy.is_none() - { - spec.policy = Some(new_policy_clone.clone()); - } - if !annotations.is_empty() - && let Some(metadata) = sandbox.metadata.as_mut() - { - metadata.annotations.extend(annotations.clone()); - } - }, - ) - .await - .map_err(|e| super::persistence_error_to_status(e, "backfill spec.policy"))?; - response_annotations = sandbox_metadata_annotations(&updated_sandbox); - info!( - sandbox_id = %sandbox_id, - "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" - ); - } - - let latest = state - .store - .get_latest_policy(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; + Some(new_policy.clone()) + }; + let _sandbox_sync_guard = if backfill_policy.is_some() { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); + let (next_version, committed_annotations) = { + let mut committed = None; + for attempt in 1..=MERGE_RETRY_LIMIT { + let latest = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - if let Some(ref current) = latest - && current.policy_hash == hash - { - response_annotations = persist_update_config_annotations( - state, - &sandbox_id, - req.expected_resource_version, - &req.annotations, - &response_annotations, - ) - .await?; - - return Ok(update_config_response( - u32::try_from(current.version).unwrap_or(0), - hash, - 0, - false, - response_annotations, - )); - } - - let next_version = latest.map_or(1, |r| r.version + 1); - let policy_id = uuid::Uuid::new_v4().to_string(); + if let Some(ref current) = latest + && current.policy_hash == hash + && current.provenance == req.annotations + { + response_annotations = persist_existing_policy_projection( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + backfill_policy.as_ref(), + ) + .await?; + if backfill_policy.is_some() { + info!( + sandbox_id = %sandbox_id, + "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" + ); + } + return Ok(update_config_response( + u32::try_from(current.version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )); + } - state - .store - .put_policy_revision(&policy_id, &sandbox_id, next_version, &payload, &hash) - .await - .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; + let next_version = latest.as_ref().map_or(1, |record| record.version + 1); + let write = AtomicPolicyRevisionWrite { + id: uuid::Uuid::new_v4().to_string(), + sandbox_id: sandbox_id.clone(), + version: next_version, + policy_payload: payload.clone(), + policy_hash: hash.clone(), + provenance: req.annotations.clone(), + expected_resource_version: req.expected_resource_version, + annotations: req.annotations.clone(), + backfill_policy: backfill_policy.clone(), + }; - let _ = state - .store - .supersede_older_policies(&sandbox_id, next_version) - .await; + match state.store.put_policy_revision_atomic(&write).await { + Ok(updated_sandbox) => { + committed = + Some((next_version, sandbox_metadata_annotations(&updated_sandbox))); + break; + } + Err(error) if error.is_unique_violation_on("objects_version_uq") => { + warn!( + sandbox_id = %sandbox_id, + attempt, + conflicting_version = next_version, + "UpdateConfig: policy version conflict, retrying" + ); + tokio::task::yield_now().await; + } + Err(error) => { + return Err(super::persistence_error_to_status( + error, + "persist policy revision", + )); + } + } + } + committed.ok_or_else(|| { + Status::aborted(format!( + "UpdateConfig: gave up after {MERGE_RETRY_LIMIT} policy version conflict retries" + )) + })? + }; + response_annotations = committed_annotations; - response_annotations = persist_update_config_annotations( - state, - &sandbox_id, - req.expected_resource_version, - &req.annotations, - &response_annotations, - ) - .await?; + if backfill_policy.is_some() { + info!( + sandbox_id = %sandbox_id, + "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" + ); + } state.sandbox_watch_bus.notify(&sandbox_id); @@ -3372,6 +3433,7 @@ fn policy_record_to_revision(record: &PolicyRecord, include_policy: bool) -> San created_at_ms: record.created_at_ms, loaded_at_ms: record.loaded_at_ms.unwrap_or(0), policy, + provenance: record.provenance.clone(), } } @@ -3660,12 +3722,19 @@ fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { } } +struct AtomicPolicyWriteContext<'a> { + expected_resource_version: u64, + provenance: &'a HashMap, + annotations: &'a HashMap, +} + async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], -) -> Result<(i64, String), Status> { + atomic_context: Option<&AtomicPolicyWriteContext<'_>>, +) -> Result<(i64, String, Option), Status> { for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -3690,26 +3759,48 @@ async fn apply_merge_operations_with_retry( if let Some(ref current) = latest && current.policy_hash == hash + && atomic_context.is_none_or(|context| current.provenance == *context.provenance) { - return Ok((current.version, hash)); + return Ok((current.version, hash, None)); } if latest.is_none() && !merged.changed { - return Ok((0, hash)); + return Ok((0, hash, None)); } let payload = new_policy.encode_to_vec(); let next_version = latest.as_ref().map_or(1, |record| record.version + 1); let policy_id = uuid::Uuid::new_v4().to_string(); - match store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) - .await - { - Ok(()) => { - let _ = store - .supersede_older_policies(sandbox_id, next_version) - .await; + let write_result = if let Some(context) = atomic_context { + store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: policy_id, + sandbox_id: sandbox_id.to_string(), + version: next_version, + policy_payload: payload, + policy_hash: hash.clone(), + provenance: context.provenance.clone(), + expected_resource_version: context.expected_resource_version, + annotations: context.annotations.clone(), + backfill_policy: None, + }) + .await + .map(Some) + } else { + store + .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) + .await + .map(|()| None) + }; + + match write_result { + Ok(updated_sandbox) => { + if atomic_context.is_none() { + let _ = store + .supersede_older_policies(sandbox_id, next_version) + .await; + } if attempt > 1 { info!( @@ -3721,7 +3812,7 @@ async fn apply_merge_operations_with_retry( ); } - return Ok((next_version, hash)); + return Ok((next_version, hash, updated_sandbox)); } Err(e) => { if e.is_unique_violation_on("objects_version_uq") { @@ -3759,7 +3850,9 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations).await + apply_merge_operations_with_retry(store, sandbox_id, None, &operations, None) + .await + .map(|(version, hash, _)| (version, hash)) } async fn remove_chunk_from_policy( @@ -3775,8 +3868,10 @@ async fn remove_chunk_from_policy( rule_name: chunk.rule_name.clone(), binary_path: chunk.binary.clone(), }], + None, ) .await + .map(|(version, hash, _)| (version, hash)) } // --------------------------------------------------------------------------- @@ -9207,8 +9302,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow, None), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny, None), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -10366,10 +10461,17 @@ mod tests { stored.spec.as_ref().unwrap().policy.is_some(), "policy should still be backfilled" ); + let revision = state + .store + .get_latest_policy("sb-annotated-backfill") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, annotations); } #[tokio::test] - async fn update_config_same_policy_hash_persists_and_returns_annotations() { + async fn update_config_same_policy_hash_with_new_provenance_creates_revision() { let state = test_server_state().await; let mut policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); openshell_policy::ensure_sandbox_process_identity(&mut policy); @@ -10404,7 +10506,7 @@ mod tests { .unwrap() .into_inner(); - assert_eq!(response.version, 1); + assert_eq!(response.version, 2); assert_eq!( response .annotations @@ -10429,6 +10531,77 @@ mod tests { .map(String::as_str), Some("same-hash-signature") ); + let latest = state + .store + .get_latest_policy("sb-same-hash") + .await + .unwrap() + .unwrap(); + assert_eq!(latest.version, 2); + assert_eq!( + latest.provenance, + HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-hash-signature".to_string(), + )]) + ); + } + + #[tokio::test] + async fn update_config_same_policy_and_provenance_is_idempotent() { + let state = test_server_state().await; + let mut policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut policy); + state + .store + .put_message(&test_sandbox( + "sb-idempotent-provenance", + "idempotent-provenance", + policy.clone(), + Vec::new(), + )) + .await + .unwrap(); + let annotations = HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-signature".to_string(), + )]); + + let first = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "idempotent-provenance".to_string(), + policy: Some(policy.clone()), + annotations: annotations.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let second = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "idempotent-provenance".to_string(), + policy: Some(policy), + annotations: annotations.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(first.version, 1); + assert_eq!(second.version, 1); + assert_eq!(second.annotations, annotations); + let revisions = state + .store + .list_policies("sb-idempotent-provenance", 10, 0) + .await + .unwrap(); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0].provenance, annotations); } #[tokio::test] @@ -10562,6 +10735,65 @@ mod tests { ); } + #[tokio::test] + async fn update_config_merge_stores_revision_provenance_atomically() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + state + .store + .put_message(&test_sandbox( + "sb-merge-provenance", + "merge-provenance", + baseline, + Vec::new(), + )) + .await + .unwrap(); + let provenance = HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "merge-signature".to_string(), + )]); + + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "merge-provenance".to_string(), + merge_operations: vec![PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::AddRule( + openshell_core::proto::AddNetworkRule { + rule_name: "allow_api_example".to_string(), + rule: Some(NetworkPolicyRule { + name: "allow_api_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }, + )), + }], + annotations: provenance.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 1); + assert_eq!(response.annotations, provenance); + let revision = state + .store + .get_latest_policy("sb-merge-provenance") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, provenance); + } + #[tokio::test] async fn update_config_backfill_empty_annotations_preserves_existing_annotations() { use openshell_core::proto::{SandboxPhase, SandboxSpec}; @@ -10909,6 +11141,15 @@ mod tests { unchanged.spec.as_ref().unwrap().policy.is_none(), "policy should still be None after failed backfill" ); + assert!( + state + .store + .get_latest_policy("sb-1") + .await + .unwrap() + .is_none(), + "failed backfill must not leave an orphan revision" + ); } #[tokio::test] @@ -11010,5 +11251,15 @@ mod tests { final_sandbox.spec.as_ref().unwrap().policy.is_some(), "policy should be backfilled after one success" ); + assert_eq!( + state + .store + .list_policies("sb-1", 10, 0) + .await + .unwrap() + .len(), + 1, + "concurrent backfills must create exactly one revision" + ); } } diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index bb32a8efd6..d63b159ab6 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -6,9 +6,13 @@ use super::{ WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ - draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record, - policy_record_from_parts, + AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, + policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts, + project_policy_revision_onto_sandbox, }; +use openshell_core::SetResourceVersion; +use openshell_core::proto::Sandbox; +use prost::Message; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool, Row}; @@ -447,6 +451,7 @@ LIMIT $3 OFFSET $4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, + provenance: Default::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; @@ -471,6 +476,104 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) Ok(()) } + pub async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + let record = policy_record_for_atomic_write(write, now_ms); + let wrapped_payload = policy_payload_from_record(&record)?; + let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + + let row = sqlx::query( + r" +SELECT payload, resource_version +FROM objects +WHERE object_type = 'sandbox' AND id = $1 +FOR UPDATE +", + ) + .bind(&write.sandbox_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| map_db_error(&e))? + .ok_or_else(|| { + PersistenceError::Database(format!("sandbox object {} not found", write.sandbox_id)) + })?; + + let sandbox_payload: Vec = row.get("payload"); + let current_version: i64 = row.try_get("resource_version").unwrap_or(1); + let current_version = current_version.max(1).cast_unsigned(); + let (mut sandbox, sandbox_changed) = + project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; + + let resulting_version = if sandbox_changed { + let result = sqlx::query( + r" +UPDATE objects +SET payload = $2, updated_at_ms = $3, resource_version = resource_version + 1 +WHERE object_type = 'sandbox' AND id = $1 AND resource_version = $4 +", + ) + .bind(&write.sandbox_id) + .bind(sandbox.encode_to_vec()) + .bind(now_ms) + .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + if result.rows_affected() != 1 { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } + current_version.saturating_add(1) + } else { + current_version + }; + + sqlx::query( + r" +INSERT INTO objects ( + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7) +", + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.id) + .bind(&write.sandbox_id) + .bind(write.version) + .bind("pending") + .bind(wrapped_payload) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + sqlx::query( + r" +UPDATE objects +SET status = 'superseded', updated_at_ms = $4 +WHERE object_type = $1 + AND scope = $2 + AND version < $3 + AND status IN ('pending', 'loaded') +", + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.sandbox_id) + .bind(write.version) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + sandbox.set_resource_version(resulting_version); + Ok(sandbox) + } + pub async fn get_latest_policy( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index efb6a9bf45..45a1d05465 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -6,10 +6,14 @@ use super::{ WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ - draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record, - policy_record_from_parts, + AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, + policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts, + project_policy_revision_onto_sandbox, }; +use openshell_core::SetResourceVersion; use openshell_core::paths::set_file_owner_only; +use openshell_core::proto::Sandbox; +use prost::Message; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Connection, Row, SqlitePool}; use std::path::{Path, PathBuf}; @@ -468,6 +472,7 @@ LIMIT ?3 OFFSET ?4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, + provenance: Default::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; @@ -492,6 +497,107 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) Ok(()) } + pub async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + let record = policy_record_for_atomic_write(write, now_ms); + let wrapped_payload = policy_payload_from_record(&record)?; + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|e| map_db_error(&e))?; + + let row = sqlx::query( + r#" +SELECT "payload", "resource_version" +FROM "objects" +WHERE "object_type" = 'sandbox' AND "id" = ?1 +"#, + ) + .bind(&write.sandbox_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| map_db_error(&e))? + .ok_or_else(|| { + PersistenceError::Database(format!("sandbox object {} not found", write.sandbox_id)) + })?; + + let sandbox_payload: Vec = row.get("payload"); + let current_version: i64 = row.try_get("resource_version").unwrap_or(1); + let current_version = current_version.max(1).cast_unsigned(); + let (mut sandbox, sandbox_changed) = + project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; + + let resulting_version = if sandbox_changed { + let result = sqlx::query( + r#" +UPDATE "objects" +SET "payload" = ?2, "updated_at_ms" = ?3, "resource_version" = "resource_version" + 1 +WHERE "object_type" = 'sandbox' AND "id" = ?1 AND "resource_version" = ?4 +"#, + ) + .bind(&write.sandbox_id) + .bind(sandbox.encode_to_vec()) + .bind(now_ms) + .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + if result.rows_affected() != 1 { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } + current_version.saturating_add(1) + } else { + current_version + }; + + sqlx::query( + r#" +INSERT INTO "objects" ( + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms" +) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) +"#, + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.id) + .bind(&write.sandbox_id) + .bind(write.version) + .bind("pending") + .bind(wrapped_payload) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + sqlx::query( + r#" +UPDATE "objects" +SET "status" = 'superseded', "updated_at_ms" = ?4 +WHERE "object_type" = ?1 + AND "scope" = ?2 + AND "version" < ?3 + AND "status" IN ('pending', 'loaded') +"#, + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.sandbox_id) + .bind(write.version) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + sandbox.set_resource_version(resulting_version); + Ok(sandbox) + } + pub async fn get_latest_policy( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 91a24f3733..c92f63827a 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ObjectType, PersistenceError, Store, generate_name, test_store}; -use crate::policy_store::PolicyStoreExt; -use openshell_core::proto::{ObjectForTest, SandboxPolicy}; +use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; +use openshell_core::proto::datamodel::v1::ObjectMeta as ProtoObjectMeta; +use openshell_core::proto::{ObjectForTest, Sandbox, SandboxPolicy, SandboxSpec}; use prost::Message; +use std::collections::HashMap as StdHashMap; #[tokio::test] async fn sqlite_put_get_round_trip() { @@ -607,6 +609,134 @@ async fn empty_labels_not_matched_by_selector() { // Policy revision tests // --------------------------------------------------------------------------- +fn policy_test_sandbox(id: &str, name: &str) -> Sandbox { + Sandbox { + metadata: Some(ProtoObjectMeta { + id: id.to_string(), + name: name.to_string(), + created_at_ms: 1, + ..Default::default() + }), + spec: Some(SandboxSpec::default()), + ..Default::default() + } +} + +#[tokio::test] +async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection() { + let store = test_store().await; + store + .put_message(&policy_test_sandbox("sandbox-atomic", "atomic")) + .await + .unwrap(); + let current = store + .get_message::("sandbox-atomic") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + let policy = SandboxPolicy { + version: 1, + ..Default::default() + }; + let provenance = StdHashMap::from([("signature".to_string(), "signed".to_string())]); + + let updated = store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "policy-atomic-1".to_string(), + sandbox_id: "sandbox-atomic".to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: "hash-atomic-1".to_string(), + provenance: provenance.clone(), + expected_resource_version: current_version, + annotations: provenance.clone(), + backfill_policy: Some(policy.clone()), + }) + .await + .unwrap(); + + assert_eq!( + updated.metadata.as_ref().unwrap().resource_version, + current_version + 1 + ); + assert_eq!(updated.metadata.as_ref().unwrap().annotations, provenance); + assert_eq!( + updated.spec.as_ref().unwrap().policy.as_ref(), + Some(&policy) + ); + let revision = store + .get_latest_policy("sandbox-atomic") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, provenance); + store + .update_policy_status("sandbox-atomic", 1, "loaded", None, Some(100)) + .await + .unwrap(); + let loaded = store + .get_latest_policy("sandbox-atomic") + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.provenance, provenance); +} + +#[tokio::test] +async fn policy_atomic_write_rolls_back_sandbox_when_revision_insert_conflicts() { + let store = test_store().await; + store + .put_message(&policy_test_sandbox("sandbox-rollback", "rollback")) + .await + .unwrap(); + let policy = SandboxPolicy::default(); + store + .put_policy_revision( + "existing-policy", + "sandbox-rollback", + 1, + &policy.encode_to_vec(), + "existing-hash", + ) + .await + .unwrap(); + let before = store + .get_message::("sandbox-rollback") + .await + .unwrap() + .unwrap(); + let before_version = before.metadata.as_ref().unwrap().resource_version; + + let error = store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "conflicting-policy".to_string(), + sandbox_id: "sandbox-rollback".to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: "conflicting-hash".to_string(), + provenance: StdHashMap::from([("signature".to_string(), "new".to_string())]), + expected_resource_version: before_version, + annotations: StdHashMap::from([("signature".to_string(), "new".to_string())]), + backfill_policy: Some(policy), + }) + .await + .unwrap_err(); + assert!(error.is_unique_violation_on("objects_version_uq")); + + let after = store + .get_message::("sandbox-rollback") + .await + .unwrap() + .unwrap(); + assert_eq!( + after.metadata.as_ref().unwrap().resource_version, + before_version + ); + assert!(after.metadata.as_ref().unwrap().annotations.is_empty()); + assert!(after.spec.as_ref().unwrap().policy.is_none()); +} + #[tokio::test] async fn policy_put_and_get_latest() { let store = test_store().await; diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 5a647704c2..70b60ee88a 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -1,12 +1,98 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::persistence::{DraftChunkRecord, PersistenceResult, PolicyRecord, Store}; +use crate::persistence::{ + DraftChunkRecord, PersistenceError, PersistenceResult, PolicyRecord, SetResourceVersion, Store, +}; use openshell_core::proto::{ - DraftChunkPayload, NetworkPolicyRule, PolicyRevisionPayload, + DraftChunkPayload, NetworkPolicyRule, PolicyRevisionPayload, Sandbox, SandboxPolicy as ProtoSandboxPolicy, }; use prost::Message; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct AtomicPolicyRevisionWrite { + pub id: String, + pub sandbox_id: String, + pub version: i64, + pub policy_payload: Vec, + pub policy_hash: String, + pub provenance: HashMap, + pub expected_resource_version: u64, + pub annotations: HashMap, + pub backfill_policy: Option, +} + +pub(crate) fn policy_record_for_atomic_write( + write: &AtomicPolicyRevisionWrite, + created_at_ms: i64, +) -> PolicyRecord { + PolicyRecord { + id: write.id.clone(), + sandbox_id: write.sandbox_id.clone(), + version: write.version, + policy_payload: write.policy_payload.clone(), + policy_hash: write.policy_hash.clone(), + status: "pending".to_string(), + load_error: None, + created_at_ms, + loaded_at_ms: None, + provenance: write.provenance.clone(), + } +} + +pub(crate) fn project_policy_revision_onto_sandbox( + write: &AtomicPolicyRevisionWrite, + payload: &[u8], + current_resource_version: u64, +) -> PersistenceResult<(Sandbox, bool)> { + if write.expected_resource_version != 0 + && write.expected_resource_version != current_resource_version + { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_resource_version), + }); + } + + let mut sandbox = Sandbox::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode sandbox payload failed: {e}")))?; + sandbox.set_resource_version(current_resource_version); + + let mut changed = false; + if let Some(backfill_policy) = write.backfill_policy.as_ref() { + let spec = sandbox + .spec + .as_mut() + .ok_or_else(|| PersistenceError::Decode("sandbox payload missing spec".to_string()))?; + match spec.policy.as_ref() { + None => { + spec.policy = Some(backfill_policy.clone()); + changed = true; + } + Some(current) if current == backfill_policy => {} + Some(_) => { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_resource_version), + }); + } + } + } + + if !write.annotations.is_empty() { + let metadata = sandbox.metadata.as_mut().ok_or_else(|| { + PersistenceError::Decode("sandbox payload missing metadata".to_string()) + })?; + for (key, value) in &write.annotations { + if metadata.annotations.get(key) != Some(value) { + metadata.annotations.insert(key.clone(), value.clone()); + changed = true; + } + } + } + + Ok((sandbox, changed)) +} pub trait PolicyStoreExt { async fn put_policy_revision( @@ -18,6 +104,13 @@ pub trait PolicyStoreExt { hash: &str, ) -> PersistenceResult<()>; + /// Atomically create a sandbox policy revision, project its annotations + /// onto sandbox metadata, and optionally backfill `spec.policy`. + async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult; + async fn get_latest_policy(&self, sandbox_id: &str) -> PersistenceResult>; #[allow(dead_code)] @@ -135,6 +228,16 @@ impl PolicyStoreExt for Store { } } + async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + match self { + Self::Postgres(store) => store.put_policy_revision_atomic(write).await, + Self::Sqlite(store) => store.put_policy_revision_atomic(write).await, + } + } + async fn get_latest_policy(&self, sandbox_id: &str) -> PersistenceResult> { match self { Self::Postgres(store) => store.get_latest_policy(sandbox_id).await, @@ -313,14 +416,14 @@ impl PolicyStoreExt for Store { } pub fn policy_payload_from_record(record: &PolicyRecord) -> PersistenceResult> { - let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!("decode policy payload failed: {e}")) - })?; + let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) + .map_err(|e| PersistenceError::Decode(format!("decode policy payload failed: {e}")))?; Ok(PolicyRevisionPayload { policy: Some(policy), hash: record.policy_hash.clone(), load_error: record.load_error.clone().unwrap_or_default(), loaded_at_ms: record.loaded_at_ms.unwrap_or(0), + provenance: record.provenance.clone(), } .encode_to_vec()) } @@ -333,12 +436,11 @@ pub fn policy_record_from_parts( payload: &[u8], created_at_ms: i64, ) -> PersistenceResult { - let wrapper = PolicyRevisionPayload::decode(payload).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!("decode policy wrapper failed: {e}")) - })?; - let policy = wrapper.policy.ok_or_else(|| { - crate::persistence::PersistenceError::Decode("policy wrapper missing policy".to_string()) - })?; + let wrapper = PolicyRevisionPayload::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode policy wrapper failed: {e}")))?; + let policy = wrapper + .policy + .ok_or_else(|| PersistenceError::Decode("policy wrapper missing policy".to_string()))?; Ok(PolicyRecord { id, sandbox_id, @@ -353,6 +455,7 @@ pub fn policy_record_from_parts( }, created_at_ms, loaded_at_ms: (wrapper.loaded_at_ms > 0).then_some(wrapper.loaded_at_ms), + provenance: wrapper.provenance, }) } @@ -371,11 +474,8 @@ pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceR None } else { Some( - NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!( - "decode draft rule failed: {e}" - )) - })?, + NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .map_err(|e| PersistenceError::Decode(format!("decode draft rule failed: {e}")))?, ) }; Ok(DraftChunkPayload { @@ -405,11 +505,8 @@ pub fn draft_chunk_record_from_parts( created_at_ms: i64, updated_at_ms: i64, ) -> PersistenceResult { - let wrapper = DraftChunkPayload::decode(payload).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!( - "decode draft chunk wrapper failed: {e}" - )) - })?; + let wrapper = DraftChunkPayload::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode draft chunk wrapper failed: {e}")))?; let proposed_rule = wrapper .proposed_rule .map(|rule| rule.encode_to_vec()) diff --git a/proto/openshell.proto b/proto/openshell.proto index f849464222..29cb343d5b 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1230,8 +1230,10 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; - // Optional annotations to merge into sandbox metadata after a successful - // sandbox-scoped update. Intended for non-secret provenance metadata. + // Complete, non-secret provenance for a sandbox policy revision. The gateway + // stores this map immutably with the revision and also merges it into sandbox + // metadata as a convenience projection. For setting-only updates, the map is + // only merged into sandbox metadata. map annotations = 9; } @@ -1356,6 +1358,8 @@ message SandboxPolicyRevision { int64 loaded_at_ms = 6; // The full policy (only populated when explicitly requested). openshell.sandbox.v1.SandboxPolicy policy = 7; + // Immutable provenance supplied with this policy revision. + map provenance = 8; } // Policy load status. @@ -1837,6 +1841,8 @@ message PolicyRevisionPayload { string load_error = 3; // When the policy version was reported as loaded (ms since epoch). 0 if unset. int64 loaded_at_ms = 4; + // Immutable provenance supplied when this revision was created. + map provenance = 5; } // Stored payload for a draft policy chunk row in the generic objects table. @@ -1880,6 +1886,7 @@ message StoredPolicyRevision { optional string load_error = 7; int64 created_at_ms = 8; optional int64 loaded_at_ms = 9; + map provenance = 10; } // Internal stored draft chunk row materialized from the generic objects table. From 1976d1e3336a34e1dda4576982dbcc6a8fe68e09 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 12:36:12 -0700 Subject: [PATCH 22/27] fix(gateway): close signed governance bypasses Signed-off-by: Drew Newberry --- architecture/gateway.md | 15 +- .../src/routes.rs | 2 +- .../src/runtime.rs | 77 +++++-- examples/governance-interceptor/Cargo.toml | 4 + examples/governance-interceptor/README.md | 18 +- examples/governance-interceptor/smoke.sh | 14 ++ examples/governance-interceptor/src/main.rs | 67 +++++- .../src/smoke_client.rs | 172 +++++++++++++++ examples/governance-interceptor/src/tests.rs | 204 +++++++++++++++++- 9 files changed, 531 insertions(+), 42 deletions(-) create mode 100644 examples/governance-interceptor/src/smoke_client.rs diff --git a/architecture/gateway.md b/architecture/gateway.md index cd1689b050..d67e7f5a5b 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,6 +52,11 @@ 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. +`SubmitPolicyAnalysis` is interceptable because proposed chunks can eventually +change active policy through the gateway's approval workflow. An interceptor +may therefore reject policy proposals while permitting telemetry-only requests. +Gateways without a matching binding retain the standard proposal behavior. + The descriptor codec uses protobuf's standard `oneof` semantics. If binary input contains multiple alternatives from one group, the last member on the wire wins. The middleware converts that selected value to ProtoJSON and @@ -61,10 +66,12 @@ invalid. Modification results are atomic per binding. After applying one binding's full JSON Patch list, the middleware re-encodes the candidate as the request's -protobuf type before accepting it. Invalid candidates follow that binding's -failure policy: fail-open restores the exact pre-binding operation, while -fail-closed rejects the request before handler dispatch. Later bindings only -observe schema-valid operations. +protobuf type and decodes those accepted bytes back to canonical ProtoJSON. +Invalid candidates follow that binding's failure policy: fail-open restores the +exact pre-binding operation, while fail-closed rejects the request before +handler dispatch. Later bindings only observe the same schema-valid operation +that the handler will receive; protobuf map entry ordering is not treated as a +semantic difference. Each interceptor evaluation selects exactly one phase payload: `modify_operation`, `validate`, or `post_commit`. Modification and validation diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs index f8e2b6ca54..649c80e670 100644 --- a/crates/openshell-gateway-interceptors/src/routes.rs +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -26,7 +26,6 @@ pub const NON_INTERCEPTABLE_METHODS: &[&str] = &[ "GetSandboxConfig", "GetSandboxProviderEnvironment", "ReportPolicyStatus", - "SubmitPolicyAnalysis", "IssueSandboxToken", "RefreshSandboxToken", "GetSandbox", @@ -152,6 +151,7 @@ mod tests { 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", "SubmitPolicyAnalysis")); assert!(!index.is_interceptable("openshell.v1.OpenShell", "GetSandbox")); assert!(!index.is_interceptable("openshell.v1.OpenShell", "WatchSandbox")); assert_eq!( diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 85fd504af0..cb1c1c0e32 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -53,6 +53,7 @@ struct ValidatedOperation { impl ValidatedOperation { fn new(codec: &ProtoJsonCodec, type_name: &str, json: Value) -> Result { let encoded = codec.encode_json_to_message(type_name, &json)?; + let json = codec.decode_bytes_to_json(type_name, &encoded)?; Ok(Self { json, encoded }) } @@ -62,15 +63,16 @@ impl ValidatedOperation { type_name: &str, patches: &[JsonPatch], ) -> Result { - let json = apply_json_patches(&self.json, patches)?; - let encoded = codec - .encode_json_to_message(type_name, &json) - .map_err(|err| { - InterceptorError::InvalidResult(format!( - "patched operation is not valid {type_name}: {err}" - )) - })?; - Ok(Self { json, encoded }) + let json = apply_json_patches(&self.json, patches).map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + })?; + Self::new(codec, type_name, json).map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + }) } } @@ -140,15 +142,17 @@ impl GatewayInterceptorRuntime { .evaluate_phase(&selector, Phase::Validate, &input_type, operation, context) .await?; - let final_encoding = self + let encoded_operation = self .codec - .encode_json_to_message(&input_type, &operation.json) + .decode_bytes_to_json(&input_type, &operation.encoded) .map_err(|err| { - Status::internal(format!("validated operation became invalid: {err}")) + Status::internal(format!( + "validated operation encoding became invalid: {err}" + )) })?; - if final_encoding != operation.encoded { + if encoded_operation != operation.json { return Err(Status::internal( - "validated operation encoding changed before dispatch", + "validated operation diverged from its encoding before dispatch", )); } let body = GrpcFrame { @@ -849,6 +853,51 @@ mod tests { assert_eq!(json["mergeOperations"][0], json!({"removeRule": {}})); } + #[tokio::test] + async fn request_middleware_accepts_semantically_equal_map_encodings() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + .unwrap(); + let runtime = GatewayInterceptorRuntime { + plan: Arc::new(ExecutionPlan::empty(routes)), + codec: codec.clone(), + }; + let request = UpdateConfigRequest { + name: "demo".to_string(), + expected_resource_version: u64::MAX - 1, + annotations: HashMap::from([ + ("policy-hash".to_string(), "sha256:v2:abc".to_string()), + ("policy-signature".to_string(), "signature".to_string()), + ("policy-signature-kid".to_string(), "kid".to_string()), + ("correlation-id".to_string(), "reload-1".to_string()), + ]), + ..UpdateConfigRequest::default() + }; + let body = GrpcFrame { + compressed: false, + message: request.encode_to_vec(), + } + .encode() + .unwrap(); + + let intercepted = runtime + .evaluate_request( + "/openshell.v1.OpenShell/UpdateConfig", + &body, + &EvaluationContext { + principal: BTreeMap::new(), + validate_current_state: None, + }, + ) + .await + .unwrap(); + let frame = GrpcFrame::decode(&intercepted.body).unwrap(); + let decoded = UpdateConfigRequest::decode(frame.message.as_slice()).unwrap(); + + assert_eq!(decoded, request); + } + #[test] fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { let codec = diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index d0ddeb2e8c..c05115ecc1 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -29,3 +29,7 @@ tonic = { version = "0.14", features = ["transport"] } [[bin]] name = "governance-interceptor" path = "src/main.rs" + +[[bin]] +name = "governance-smoke-client" +path = "src/smoke_client.rs" diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index a8e163e5ca..032778ad5e 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -17,7 +17,11 @@ authoritative profile source. annotation that is used to verify the policy - sandbox creation evaluations add a `correlation_id` log annotation for gateway audit logs, plus non-secret policy hash/signing key metadata -- users cannot replace or merge sandbox policy after sandbox creation +- sandbox policy synchronization must carry the current signed governance + policy; unsigned, stale, or modified policies are denied for every caller +- sandbox policy analysis may report telemetry, but sandbox-authored policy + proposals are denied before they reach the gateway handler +- `proposal_approval_mode=auto` is blocked at both sandbox and global scope - users cannot import or update provider profiles outside the vended set - provider profile deletion is blocked by the interceptor @@ -58,6 +62,13 @@ propagate through the normal sandbox config polling path. Static baseline changes that the gateway rejects for existing sandboxes are logged and still apply to newly created sandboxes. +The example also validates `SubmitPolicyAnalysis`. Requests without proposed +policy chunks remain available for denial and network-activity telemetry. +Requests containing proposed chunks are denied, so a sandbox cannot use the +gateway's optional auto-approval path to widen its governed policy. This rule +belongs to the example: gateways without this binding retain the standard +proposal workflow. + 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 @@ -109,3 +120,8 @@ To run the governance smoke test suite and stop the gateway when it completes: ```shell ./smoke.sh --test-suite ``` + +The suite uses a gateway-signed JWT for the created sandbox identity to attempt +an unsigned policy widening and a policy proposal. It verifies that both are +denied, telemetry is accepted, and the active policy version and hash remain +unchanged. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index a51f99d5d8..8a906bc2a0 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -433,6 +433,7 @@ EOF run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy expect_failure "denies disallowed provider create" "${CLI[@]}" provider create --name bitbucket --type bitbucket --credential BITBUCKET_TOKEN=dummy + expect_failure "denies automatic proposal approval" "${CLI[@]}" settings set --global --key proposal_approval_mode --value auto --yes run_step "creates sandbox with selected github provider" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --provider github --no-auto-providers --keep --no-tty -- /bin/sh -lc true expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" @@ -442,6 +443,19 @@ EOF expect_output_contains "effective policy has github provider layer" "_provider_github" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json expect_output_not_contains "effective policy omits unselected slack layer" "_provider_slack" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + local sandbox_id + sandbox_id="$("${CLI[@]}" sandbox get "$SANDBOX_NAME" | awk '/Id:/ && !found { print $2; found=1 }')" + if [[ -z "$sandbox_id" ]]; then + fail "reads governed sandbox id" + fi + pass "reads governed sandbox id" + + run_step \ + "denies authenticated governance bypasses without changing policy" \ + "$EXAMPLE_DIR/target/debug/governance-smoke-client" \ + "$GATEWAY_ENDPOINT" "$SANDBOX_NAME" "$sandbox_id" \ + "$JWT_DIR/signing.pem" "$RUN_ID" "$RUN_ID" + local initial_policy_signature initial_policy_signature="$(policy_signature_for_sandbox "$SANDBOX_NAME")" if [[ -z "$initial_policy_signature" ]]; then diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 83930ceb7f..54b01bdd4d 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -315,6 +315,11 @@ impl GovernanceInterceptorService { "UpdateConfig", &[GatewayInterceptorPhase::Validate], ), + binding( + "govern-submit-policy-analysis", + "SubmitPolicyAnalysis", + &[GatewayInterceptorPhase::Validate], + ), binding( "govern-import-provider-profiles", "ImportProviderProfiles", @@ -371,14 +376,12 @@ impl GovernanceInterceptorService { ("CreateProvider", interceptor_evaluation::Phase::Validate(_)) => { Ok(self.validate_create_provider(&operation, &profile_state.ids)) } - ("UpdateConfig", interceptor_evaluation::Phase::Validate(_)) => { - Ok(validate_update_config( - &operation, - &evaluation.principal, - &policy_state, - &self.policy_signer, - )) - } + ("UpdateConfig", interceptor_evaluation::Phase::Validate(_)) => Ok( + validate_update_config(&operation, &policy_state, &self.policy_signer), + ), + ("SubmitPolicyAnalysis", interceptor_evaluation::Phase::Validate(_)) => Ok( + validate_submit_policy_analysis(&operation, &evaluation.principal), + ), ("ImportProviderProfiles", interceptor_evaluation::Phase::Validate(_)) => { Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) } @@ -665,12 +668,13 @@ fn validate_signed_policy_payload( fn validate_update_config( operation: &Value, - principal: &HashMap, policy_state: &PolicyState, policy_signer: &PolicySigner, ) -> InterceptorResult { - if principal.get("kind").is_some_and(|kind| kind == "sandbox") { - return allow(); + if requests_auto_proposal_approval(operation) { + return deny( + "automatic policy proposal approval is blocked by provider profile governance", + ); } let is_global = operation .get("global") @@ -694,6 +698,47 @@ fn validate_update_config( } } +fn requests_auto_proposal_approval(operation: &Value) -> bool { + let setting_key = operation + .get("settingKey") + .or_else(|| operation.get("setting_key")) + .and_then(Value::as_str); + if setting_key != Some("proposal_approval_mode") { + return false; + } + + operation + .get("settingValue") + .or_else(|| operation.get("setting_value")) + .and_then(|value| { + value + .get("stringValue") + .or_else(|| value.get("string_value")) + }) + .and_then(Value::as_str) + == Some("auto") +} + +fn validate_submit_policy_analysis( + operation: &Value, + principal: &HashMap, +) -> InterceptorResult { + if principal.get("kind").map(String::as_str) != Some("sandbox") { + return deny("policy analysis requires an authenticated sandbox principal"); + } + + match operation + .get("proposedChunks") + .or_else(|| operation.get("proposed_chunks")) + { + Some(Value::Array(chunks)) if !chunks.is_empty() => { + deny("sandbox-authored policy proposals are blocked by provider profile governance") + } + Some(Value::Array(_)) | None => allow(), + Some(_) => deny("policy analysis proposed chunks must be an array"), + } +} + fn validate_update_config_policy( operation: &Value, policy_state: &PolicyState, diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs new file mode 100644 index 0000000000..e1dfde4038 --- /dev/null +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authenticated negative-path client for the governance example smoke suite. + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use openshell_core::proto::{ + GetSandboxConfigRequest, NetworkActivitySummary, PolicyChunk, SubmitPolicyAnalysisRequest, + UpdateConfigRequest, open_shell_client::OpenShellClient, +}; +use serde::Serialize; +use tonic::Code; +use tonic::metadata::AsciiMetadataValue; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; + +#[derive(Serialize)] +struct SandboxJwtClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + sandbox_id: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let usage = "usage: governance-smoke-client \ + "; + let endpoint = args.next().ok_or(usage)?; + let sandbox_name = args.next().ok_or(usage)?; + let sandbox_id = args.next().ok_or(usage)?; + let signing_key_path = args.next().ok_or(usage)?; + let gateway_kid = args.next().ok_or(usage)?; + let gateway_id = args.next().ok_or(usage)?; + if args.next().is_some() { + return Err(usage.into()); + } + + let token = mint_sandbox_token( + &std::fs::read(signing_key_path)?, + &gateway_kid, + &gateway_id, + &sandbox_id, + )?; + let channel = Channel::from_shared(endpoint)?.connect().await?; + let bearer = AsciiMetadataValue::try_from(format!("Bearer {token}"))?; + let interceptor = move |mut request: tonic::Request<()>| { + request + .metadata_mut() + .insert("authorization", bearer.clone()); + Ok(request) + }; + let mut client = OpenShellClient::new(InterceptedService::new(channel, interceptor)); + + let before = client + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_id: sandbox_id.clone(), + }) + .await? + .into_inner(); + let mut widened_policy = before + .policy + .clone() + .ok_or("governed sandbox did not have an active policy")?; + let mut added_rule = widened_policy + .network_policies + .values() + .next() + .cloned() + .ok_or("governed policy did not contain a network rule to clone")?; + added_rule.name = "sandbox-added".to_string(); + let endpoint_to_change = added_rule + .endpoints + .first_mut() + .ok_or("governed policy network rule did not contain an endpoint")?; + endpoint_to_change.host = "sandbox-added.example".to_string(); + widened_policy + .network_policies + .insert("sandbox_added".to_string(), added_rule.clone()); + + let policy_result = client + .update_config(UpdateConfigRequest { + name: sandbox_name.clone(), + policy: Some(widened_policy), + ..Default::default() + }) + .await; + expect_permission_denied(policy_result, "unsigned sandbox policy widening")?; + + let proposal_result = client + .submit_policy_analysis(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + proposed_chunks: vec![PolicyChunk { + rule_name: "sandbox_added".to_string(), + proposed_rule: Some(added_rule), + rationale: "authenticated governance bypass regression".to_string(), + ..Default::default() + }], + analysis_mode: "agent_authored".to_string(), + ..Default::default() + }) + .await; + expect_permission_denied(proposal_result, "sandbox-authored policy proposal")?; + + client + .submit_policy_analysis(SubmitPolicyAnalysisRequest { + name: sandbox_name, + network_activity_summaries: vec![NetworkActivitySummary { + network_activity_count: 1, + ..Default::default() + }], + analysis_mode: "activity".to_string(), + ..Default::default() + }) + .await + .map_err(|status| format!("telemetry-only policy analysis was denied: {status}"))?; + + let after = client + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id }) + .await? + .into_inner(); + if after.version != before.version || after.policy_hash != before.policy_hash { + return Err(format!( + "denied sandbox requests changed active policy: before version/hash={}/{}, after={}/{}", + before.version, before.policy_hash, after.version, after.policy_hash + ) + .into()); + } + + println!("authenticated governance bypasses denied; telemetry accepted; policy unchanged"); + Ok(()) +} + +fn mint_sandbox_token( + signing_key_pem: &[u8], + kid: &str, + gateway_id: &str, + sandbox_id: &str, +) -> Result> { + let identity = format!("openshell-gateway:{gateway_id}"); + let claims = SandboxJwtClaims { + sub: format!("spiffe://openshell/sandbox/{sandbox_id}"), + iss: identity.clone(), + aud: identity, + iat: 0, + exp: 0, + sandbox_id: sandbox_id.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(kid.to_string()); + let key = EncodingKey::from_ed_pem(signing_key_pem)?; + Ok(encode(&header, &claims, &key)?) +} + +fn expect_permission_denied( + result: Result, tonic::Status>, + operation: &str, +) -> Result<(), Box> { + match result { + Err(status) if status.code() == Code::PermissionDenied => Ok(()), + Err(status) => Err(format!( + "{operation} failed with {}, expected permission denied: {}", + status.code(), + status.message() + ) + .into()), + Ok(_) => Err(format!("{operation} unexpectedly succeeded").into()), + } +} diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index d126b62b5c..4575fe2ac8 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -47,6 +47,21 @@ fn evaluation( } } +fn sandbox_evaluation( + method: &str, + phase: GatewayInterceptorPhase, + operation: Value, +) -> InterceptorEvaluation { + let mut evaluation = evaluation(method, phase, operation); + evaluation + .principal + .insert("kind".to_string(), "sandbox".to_string()); + evaluation + .principal + .insert("sandbox_id".to_string(), "demo-id".to_string()); + evaluation +} + fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec { service.current_profile_state().ids } @@ -211,6 +226,7 @@ fn manifest_declares_governance_bindings() { assert!(ids.contains(&"govern-update-provider-profiles")); assert!(ids.contains(&"govern-delete-provider-profile")); assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-submit-policy-analysis")); assert!(ids.contains(&"govern-create-sandbox")); assert!(!ids.contains(&"govern-attach-provider")); assert!(!ids.contains(&"govern-detach-provider")); @@ -571,6 +587,60 @@ fn signed_governance_policy_update_is_allowed() { assert!(result.allowed); } +#[test] +fn sandbox_policy_sync_requires_current_signed_governance_policy() { + let service = service(); + let state = policy_state(&service); + + let unsigned = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": state.policy.clone()}), + )) + .unwrap(); + assert!(!unsigned.allowed); + assert!(unsigned.reason.contains("governance annotations")); + + let signed = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": state.policy.clone(), + "annotations": policy_update_annotations( + &state, + "governance:sandbox-sync:test", + ), + }), + )) + .unwrap(); + assert!(signed.allowed); + + let mut widened = state.policy.clone(); + widened["networkPolicies"]["sandbox_added"] = json!({ + "name": "sandbox-added", + "endpoints": [{"host": "sandbox-added.example", "port": 443}], + }); + let copied_annotations = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": widened, + "annotations": policy_update_annotations( + &state, + "governance:sandbox-sync:copied", + ), + }), + )) + .unwrap(); + assert!(!copied_annotations.allowed); + assert!(copied_annotations.reason.contains("invalid")); +} + #[test] fn stale_governance_policy_update_is_denied_after_reload() { let service = service(); @@ -771,17 +841,129 @@ fn policy_update_and_merge_are_denied() { .unwrap(); assert!(global_policy_update.allowed); - let mut sandbox_policy_sync = evaluation( - "UpdateConfig", + let sandbox_policy_sync = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": {"version": 1}}), + )) + .unwrap(); + assert!(!sandbox_policy_sync.allowed); +} + +#[test] +fn automatic_proposal_approval_settings_are_denied() { + let service = service(); + + for operation in [ + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "auto"}, + }), + json!({ + "name": "demo", + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "auto"}, + }), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("automatic policy proposal approval")); + } + + for operation in [ + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "manual"}, + }), + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "deleteSetting": true, + }), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); + } +} + +#[test] +fn sandbox_policy_analysis_allows_telemetry_but_denies_proposals() { + let service = service(); + + for operation in [ + json!({ + "name": "demo", + "networkActivitySummaries": [{"networkActivityCount": 1}], + }), + json!({ + "name": "demo", + "summaries": [{"host": "denied.example", "port": 443}], + }), + json!({"name": "demo", "proposedChunks": []}), + ] { + let result = service + .evaluate_inner(&sandbox_evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); + } + + let proposal = service + .evaluate_inner(&sandbox_evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "proposedChunks": [{"ruleName": "sandbox-added"}], + }), + )) + .unwrap(); + assert!(!proposal.allowed); + assert!( + proposal + .reason + .contains("sandbox-authored policy proposals") + ); +} + +#[test] +fn policy_analysis_fails_closed_without_a_sandbox_principal() { + let service = service(); + + let missing = service + .evaluate_inner(&evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + json!({"name": "demo"}), + )) + .unwrap(); + assert!(!missing.allowed); + + let mut user = evaluation( + "SubmitPolicyAnalysis", GatewayInterceptorPhase::Validate, - json!({"name": "demo", "policy": {"version": 1}}), + json!({"name": "demo"}), ); - sandbox_policy_sync - .principal - .insert("kind".to_string(), "sandbox".to_string()); - sandbox_policy_sync - .principal - .insert("sandbox_id".to_string(), "demo-id".to_string()); - let sandbox_policy_sync = service.evaluate_inner(&sandbox_policy_sync).unwrap(); - assert!(sandbox_policy_sync.allowed); + user.principal + .insert("kind".to_string(), "user".to_string()); + let user = service.evaluate_inner(&user).unwrap(); + assert!(!user.allowed); } From 5b59745079ff15f070dc16b181f8e46762ec2c9f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 13:57:39 -0700 Subject: [PATCH 23/27] fix(gateway): isolate interceptor secrets and authority Signed-off-by: Drew Newberry --- architecture/gateway.md | 14 + crates/openshell-core/src/config.rs | 54 +- crates/openshell-core/src/lib.rs | 5 +- .../src/plan.rs | 507 +++++++++++++++++- .../src/proto_json.rs | 317 ++++++++++- .../src/runtime.rs | 138 ++++- docs/reference/gateway-config.mdx | 19 +- examples/governance-interceptor/README.md | 29 + examples/governance-interceptor/smoke.sh | 29 + proto/compute_driver.proto | 3 +- proto/datamodel.proto | 4 +- proto/inference.proto | 3 +- proto/openshell.proto | 19 +- proto/options.proto | 14 + tasks/python.toml | 4 + 15 files changed, 1110 insertions(+), 49 deletions(-) create mode 100644 proto/options.proto diff --git a/architecture/gateway.md b/architecture/gateway.md index d67e7f5a5b..c688f9cc07 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -52,6 +52,20 @@ 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. +Each configured interceptor selects a binding policy. `dynamic` accepts valid +manifest declarations and preserves the compatibility behavior. `allowlist` +enables only operator-configured RPCs and phases, while `exact` requires the +configured and declared sets to match. Strict policies match by RPC rather than +manifest binding ID, so renaming a binding does not change authority. Provider +profile sources remain a separate operator-controlled capability. + +The protobuf schema marks dedicated credential, token, and refresh-material +fields with a custom secret option. The middleware recursively omits those +fields from every request and post-commit response sent to an interceptor while +retaining the complete protobuf operation for handler dispatch. JSON Patch +paths and source paths cannot select an omitted field or replace a containing +object. There is no configuration that exposes annotated fields. + `SubmitPolicyAnalysis` is interceptable because proposed chunks can eventually change active policy through the gateway's approval workflow. An interceptor may therefore reject policy proposals while permitting telemetry-only requests. diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index e5a894d602..36a2a4ec64 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -577,12 +577,31 @@ pub struct GatewayInterceptorConfig { /// 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. + /// Controls whether manifest bindings are dynamic, allowlisted, or must + /// exactly match operator configuration. + #[serde(default)] + pub binding_policy: GatewayInterceptorBindingPolicy, + /// Binding configuration. Its validation and authorization semantics are + /// selected by `binding_policy`. #[serde(default)] pub bindings: Vec, } +/// Operator policy for authorizing interceptor manifest bindings. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorBindingPolicy { + /// Preserve manifest-controlled binding discovery. Configured bindings + /// may narrow or disable manifest declarations. + #[default] + Dynamic, + /// Enable only configured RPC selectors and phases. Extra manifest + /// declarations are ignored. + Allowlist, + /// Require configured and manifest RPC selectors and phases to match. + Exact, +} + /// One configured source in the gateway's effective provider-profile catalog. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] @@ -604,7 +623,7 @@ pub enum GatewayInterceptorFailurePolicy { FailOpen, } -/// Configured override for a manifest binding. +/// Configured binding authorization or dynamic-manifest override. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct GatewayInterceptorBindingOverride { @@ -939,10 +958,11 @@ mod tests { #[cfg(unix)] use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorFailurePolicy, - GatewayJwtConfig, GatewayProviderProfileSourceConfig, detect_driver, - docker_host_unix_socket_path, is_unix_socket, normalize_compute_driver_name, - podman_socket_candidates_from_env, podman_socket_responds, + ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, + GatewayProviderProfileSourceConfig, detect_driver, docker_host_unix_socket_path, + is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env, + podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -1041,6 +1061,26 @@ mod tests { assert!(err.to_string().contains("unknown variant `ignore`")); } + #[test] + fn gateway_interceptor_binding_policy_defaults_and_parses_strict_modes() { + let defaulted: GatewayInterceptorConfig = serde_json::from_value(serde_json::json!({ + "name": "governance", + "grpc_endpoint": "unix:///tmp/governance.sock" + })) + .unwrap(); + let allowlist: GatewayInterceptorBindingPolicy = + serde_json::from_value(serde_json::json!("allowlist")).unwrap(); + let exact: GatewayInterceptorBindingPolicy = + serde_json::from_value(serde_json::json!("exact")).unwrap(); + + assert_eq!( + defaulted.binding_policy, + GatewayInterceptorBindingPolicy::Dynamic + ); + assert_eq!(allowlist, GatewayInterceptorBindingPolicy::Allowlist); + assert_eq!(exact, GatewayInterceptorBindingPolicy::Exact); + } + #[test] fn grpc_rate_limit_requires_positive_pair() { assert!(Config::new(None).grpc_rate_limit().is_none()); diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index e9128fc53d..c5e24d17a1 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -39,8 +39,9 @@ pub mod time; pub use config::{ ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, - GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, TlsConfig, + GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, + MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs index c4038969ba..b65d5612fd 100644 --- a/crates/openshell-gateway-interceptors/src/plan.rs +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -9,8 +9,8 @@ use std::time::Duration; use hyper_util::rt::TokioIo; use openshell_core::config::{ - GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, + GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, }; use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, @@ -21,7 +21,7 @@ use tonic::Request; use tonic::codegen::http::Uri; use tonic::transport::{Channel, Endpoint}; use tower::service_fn; -use tracing::info; +use tracing::{info, warn}; use crate::profile_source::GatewayInterceptorProfileSource; use crate::routes::OpenShellRouteIndex; @@ -210,28 +210,48 @@ impl ExecutionPlan { )) })? .into_inner(); - let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; - let service_default = config - .failure_policy - .map(FailurePolicy::from) - .or(manifest_default) - .unwrap_or(FailurePolicy::FailClosed); + let service_default = match config.binding_policy { + GatewayInterceptorBindingPolicy::Dynamic => { + let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; + config + .failure_policy + .map(FailurePolicy::from) + .or(manifest_default) + .unwrap_or(FailurePolicy::FailClosed) + } + GatewayInterceptorBindingPolicy::Allowlist + | GatewayInterceptorBindingPolicy::Exact => config + .failure_policy + .map_or(FailurePolicy::FailClosed, FailurePolicy::from), + }; 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( + let normalized_bindings = match config.binding_policy { + GatewayInterceptorBindingPolicy::Dynamic => { + warn!( + interceptor = %config.name, + "interceptor uses dynamic binding policy; valid manifest bindings are operator-authorized" + ); + normalize_dynamic_bindings( + &config.name, + &manifest.bindings, + service_default, + &config.bindings, + )? + } + GatewayInterceptorBindingPolicy::Allowlist + | GatewayInterceptorBindingPolicy::Exact => normalize_strict_bindings( &config.name, - manifest_binding, + &manifest.bindings, service_default, - &override_index, - )?; - let Some(normalized) = normalized else { - continue; - }; + &config.bindings, + config.binding_policy, + )?, + }; + for normalized in normalized_bindings { if !routes .is_interceptable(&normalized.selector.service, &normalized.selector.method) { @@ -336,6 +356,263 @@ struct NormalizedBinding { failure_policy: FailurePolicy, } +#[derive(Debug)] +struct StrictBindingConfig<'a> { + selector: RpcSelector, + phases: Vec, + source: &'a GatewayInterceptorBindingOverride, +} + +fn normalize_dynamic_bindings( + interceptor_name: &str, + manifest_bindings: &[InterceptorBinding], + service_default: FailurePolicy, + overrides: &[GatewayInterceptorBindingOverride], +) -> Result> { + let override_index = OverrideIndex::new(overrides)?; + let mut normalized = Vec::new(); + for binding in manifest_bindings { + if let Some(binding) = + normalize_binding(interceptor_name, binding, service_default, &override_index)? + { + normalized.push(binding); + } + } + Ok(normalized) +} + +fn normalize_strict_bindings( + interceptor_name: &str, + manifest_bindings: &[InterceptorBinding], + service_default: FailurePolicy, + configured_bindings: &[GatewayInterceptorBindingOverride], + policy: GatewayInterceptorBindingPolicy, +) -> Result> { + debug_assert!(matches!( + policy, + GatewayInterceptorBindingPolicy::Allowlist | GatewayInterceptorBindingPolicy::Exact + )); + + let configured = normalize_strict_config(interceptor_name, configured_bindings)?; + let configured_selectors = configured + .iter() + .map(|binding| binding.selector.clone()) + .collect::>(); + let mut normalized = Vec::with_capacity(configured.len()); + + for binding_config in configured { + let matches = manifest_bindings + .iter() + .filter(|binding| { + selector_from_proto(binding.selector.as_ref()) + .is_ok_and(|selector| selector == binding_config.selector) + }) + .collect::>(); + match matches.as_slice() { + [] => { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' did not declare configured binding '{}'", + binding_config.selector.rpc() + ))); + } + [manifest_binding] => normalized.push(normalize_strict_binding( + interceptor_name, + manifest_binding, + &binding_config, + service_default, + policy, + )?), + _ => { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared multiple bindings for configured RPC '{}'", + binding_config.selector.rpc() + ))); + } + } + } + + for manifest_binding in manifest_bindings { + let selector = match selector_from_proto(manifest_binding.selector.as_ref()) { + Ok(selector) => selector, + Err(err) if policy == GatewayInterceptorBindingPolicy::Allowlist => { + warn!( + interceptor = %interceptor_name, + binding_id = %manifest_binding.id, + error = %err, + "ignoring malformed manifest binding outside the operator allowlist" + ); + continue; + } + Err(err) => return Err(err), + }; + if configured_selectors.contains(&selector) { + continue; + } + if policy == GatewayInterceptorBindingPolicy::Exact { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared unconfigured binding '{}'", + selector.rpc() + ))); + } + warn!( + interceptor = %interceptor_name, + binding_id = %manifest_binding.id, + rpc = %selector.rpc(), + "ignoring manifest binding outside the operator allowlist" + ); + } + + Ok(normalized) +} + +fn normalize_strict_config<'a>( + interceptor_name: &str, + bindings: &'a [GatewayInterceptorBindingOverride], +) -> Result>> { + let mut selectors = BTreeSet::new(); + let mut normalized = Vec::with_capacity(bindings.len()); + for binding in bindings { + if binding.id.is_some() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' strict binding configuration must select by RPC, not id" + ))); + } + if binding.disabled { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' strict binding configuration cannot use disabled=true; omit the binding instead" + ))); + } + let selector = strict_config_selector(binding)?; + if !selectors.insert(selector.clone()) { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' has duplicate configured binding '{}'", + selector.rpc() + ))); + } + let configured_phases = binding.phases.as_ref().ok_or_else(|| { + InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' requires phases", + selector.rpc() + )) + })?; + if configured_phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' requires at least one phase", + selector.rpc() + ))); + } + let phases = configured_phases + .iter() + .copied() + .map(Phase::from) + .collect::>(); + if phases.iter().copied().collect::>().len() != phases.len() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' contains duplicate phases", + selector.rpc() + ))); + } + normalized.push(StrictBindingConfig { + selector, + phases, + source: binding, + }); + } + Ok(normalized) +} + +fn strict_config_selector(binding: &GatewayInterceptorBindingOverride) -> Result { + let rpc = binding + .rpc + .as_deref() + .filter(|value| !value.trim().is_empty()); + let service = binding + .service + .as_deref() + .filter(|value| !value.trim().is_empty()); + let method = binding + .method + .as_deref() + .filter(|value| !value.trim().is_empty()); + match (rpc, service, method) { + (Some(rpc), None, None) => parse_rpc_selector(rpc), + (None, Some(service), Some(method)) => { + Ok(RpcSelector::new(service.trim(), method.trim())) + } + (None, None, None) => Err(InterceptorError::Config( + "strict binding configuration requires rpc or service+method".to_string(), + )), + _ => Err(InterceptorError::Config( + "strict binding configuration requires exactly one selector form: rpc or service+method" + .to_string(), + )), + } +} + +fn normalize_strict_binding( + interceptor_name: &str, + manifest_binding: &InterceptorBinding, + config: &StrictBindingConfig<'_>, + service_default: FailurePolicy, + policy: GatewayInterceptorBindingPolicy, +) -> Result { + let binding_id = manifest_binding.id.trim(); + if binding_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared a binding without id" + ))); + } + let manifest_phases = manifest_binding + .phases + .iter() + .map(|phase| { + GatewayInterceptorPhase::try_from(*phase) + .map_err(|_| InterceptorError::Config("unknown binding phase".to_string())) + .and_then(Phase::try_from) + }) + .collect::>>()?; + if manifest_phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' declares no phases" + ))); + } + let configured_phases = config.phases.iter().copied().collect::>(); + let phases_match = match policy { + GatewayInterceptorBindingPolicy::Allowlist => configured_phases.is_subset(&manifest_phases), + GatewayInterceptorBindingPolicy::Exact => configured_phases == manifest_phases, + GatewayInterceptorBindingPolicy::Dynamic => unreachable!("strict policy required"), + }; + if !phases_match { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{}' phases do not satisfy {} policy", + config.selector.rpc(), + match policy { + GatewayInterceptorBindingPolicy::Allowlist => "allowlist", + GatewayInterceptorBindingPolicy::Exact => "exact", + GatewayInterceptorBindingPolicy::Dynamic => unreachable!(), + } + ))); + } + + let failure_policy = config + .source + .failure_policy + .map_or(service_default, FailurePolicy::from); + if config.phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", + failure_policy.as_str() + ))); + } + + Ok(NormalizedBinding { + binding_id: binding_id.to_string(), + selector: config.selector.clone(), + phases: config.phases.clone(), + failure_policy, + }) +} + #[derive(Debug)] struct OverrideIndex<'a> { by_id: HashMap<&'a str, &'a GatewayInterceptorBindingOverride>, @@ -616,13 +893,44 @@ async fn connect_unix_endpoint(path: PathBuf) -> Result { #[cfg(test)] mod tests { - use openshell_core::config::GatewayInterceptorConfig; + use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorPhaseConfig, + }; use openshell_core::proto::gateway_interceptor::v1::{ GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, }; use super::*; + fn manifest_binding( + id: &str, + rpc: &str, + phases: &[GatewayInterceptorPhase], + ) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + selector: Some(InterceptorSelector { + rpc: rpc.to_string(), + service: String::new(), + method: String::new(), + }), + phases: phases.iter().map(|phase| *phase as i32).collect(), + failure_policy: "fail_open".to_string(), + } + } + + fn strict_binding( + rpc: &str, + phases: Vec, + ) -> GatewayInterceptorBindingOverride { + GatewayInterceptorBindingOverride { + rpc: Some(rpc.to_string()), + phases: Some(phases), + ..GatewayInterceptorBindingOverride::default() + } + } + #[test] fn parses_timeout_suffixes() { assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); @@ -655,6 +963,167 @@ mod tests { ); } + #[test] + fn interceptor_binding_policy_defaults_to_dynamic() { + assert_eq!( + GatewayInterceptorConfig::default().binding_policy, + GatewayInterceptorBindingPolicy::Dynamic + ); + } + + #[test] + fn allowlist_enables_only_configured_phases_and_ignores_manifest_policy() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let manifest = vec![ + manifest_binding( + "create", + rpc, + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + manifest_binding( + "update", + "openshell.v1.OpenShell/UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + ]; + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + + let normalized = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap(); + + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].binding_id, "create"); + assert_eq!(normalized[0].phases, vec![Phase::Validate]); + assert_eq!(normalized[0].failure_policy, FailurePolicy::FailClosed); + } + + #[test] + fn allowlist_requires_every_configured_binding_and_phase() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let manifest = vec![manifest_binding( + "create", + rpc, + &[GatewayInterceptorPhase::Validate], + )]; + let missing_phase = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::ModifyOperation], + )]; + let missing_rpc = vec![strict_binding( + "openshell.v1.OpenShell/UpdateConfig", + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + + let phase_err = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &missing_phase, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + let rpc_err = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &missing_rpc, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + + assert!(phase_err.to_string().contains("do not satisfy allowlist")); + assert!( + rpc_err + .to_string() + .contains("did not declare configured binding") + ); + } + + #[test] + fn exact_rejects_extra_bindings_and_phases() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + let extra_binding = vec![ + manifest_binding("create", rpc, &[GatewayInterceptorPhase::Validate]), + manifest_binding( + "update", + "openshell.v1.OpenShell/UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + ]; + let extra_phase = vec![manifest_binding( + "create", + rpc, + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + )]; + + let binding_err = normalize_strict_bindings( + "governance", + &extra_binding, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Exact, + ) + .unwrap_err(); + let phase_err = normalize_strict_bindings( + "governance", + &extra_phase, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Exact, + ) + .unwrap_err(); + + assert!(binding_err.to_string().contains("unconfigured binding")); + assert!(phase_err.to_string().contains("do not satisfy exact")); + } + + #[test] + fn strict_policies_match_by_rpc_and_reject_id_or_ambiguous_manifest_entries() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let mut configured_with_id = + strict_binding(rpc, vec![GatewayInterceptorPhaseConfig::Validate]); + configured_with_id.id = Some("create".to_string()); + let id_err = normalize_strict_config("governance", &[configured_with_id]).unwrap_err(); + assert!(id_err.to_string().contains("select by RPC, not id")); + + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + let duplicated = vec![ + manifest_binding("create-a", rpc, &[GatewayInterceptorPhase::Validate]), + manifest_binding("create-b", rpc, &[GatewayInterceptorPhase::Validate]), + ]; + let duplicate_err = normalize_strict_bindings( + "governance", + &duplicated, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + assert!(duplicate_err.to_string().contains("multiple bindings")); + } + #[test] fn binding_failure_policy_rejects_ignore() { let overrides = Vec::new(); diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index 78756b60e4..caba3b94fe 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -3,10 +3,14 @@ //! Descriptor-backed protobuf and `ProtoJSON` conversion. +use std::collections::HashSet; use std::sync::Arc; use prost::Message as _; -use prost_reflect::{DescriptorPool, DeserializeOptions, DynamicMessage, MessageDescriptor}; +use prost_reflect::{ + DescriptorPool, DeserializeOptions, DynamicMessage, ExtensionDescriptor, FieldDescriptor, Kind, + MessageDescriptor, ReflectMessage as _, Value as ReflectValue, +}; use serde_json::Value; use crate::{InterceptorError, Result}; @@ -19,15 +23,20 @@ use crate::{InterceptorError, Result}; #[derive(Debug, Clone)] pub struct ProtoJsonCodec { pool: Arc, + secret_option: Option, } +const SECRET_OPTION: &str = "openshell.options.v1.secret"; + impl ProtoJsonCodec { pub fn from_descriptor_set(bytes: &[u8]) -> Result { let pool = DescriptorPool::decode(bytes).map_err(|err| { InterceptorError::Config(format!("decode protobuf descriptor set: {err}")) })?; + let secret_option = pool.get_extension_by_name(SECRET_OPTION); Ok(Self { pool: Arc::new(pool), + secret_option, }) } @@ -68,6 +77,42 @@ impl ProtoJsonCodec { &self.pool } + /// Decodes a protobuf message into the view exposed to gateway + /// interceptors, recursively omitting fields marked with the `OpenShell` + /// secret field option. + pub(crate) fn decode_bytes_to_interceptor_json( + &self, + type_name: &str, + bytes: &[u8], + ) -> Result { + let descriptor = self.message_descriptor(type_name)?; + let mut message = DynamicMessage::decode(descriptor, bytes).map_err(|err| { + InterceptorError::Transcode(format!("decode {type_name} protobuf message: {err}")) + })?; + self.omit_secrets(&mut message); + serde_json::to_value(message).map_err(|err| { + InterceptorError::Transcode(format!("serialize {type_name} as ProtoJSON: {err}")) + }) + } + + /// Rejects a JSON Pointer that could read, test, remove, or replace a + /// secret field. Selecting a containing message is also rejected because + /// replacing the parent could mutate an omitted descendant. + pub(crate) fn ensure_interceptor_patch_path_visible( + &self, + type_name: &str, + pointer: &str, + ) -> Result<()> { + let descriptor = self.message_descriptor(type_name)?; + let segments = parse_json_pointer(pointer)?; + if self.pointer_intersects_secret(&descriptor, &segments) { + return Err(InterceptorError::InvalidResult(format!( + "JSON patch path '{pointer}' targets an omitted secret field" + ))); + } + Ok(()) + } + pub(crate) fn decode_bytes_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { let descriptor = self.message_descriptor(type_name)?; let message = DynamicMessage::decode(descriptor, bytes).map_err(|err| { @@ -86,6 +131,158 @@ impl ProtoJsonCodec { )) }) } + + fn omit_secrets(&self, message: &mut DynamicMessage) { + let fields = message.descriptor().fields().collect::>(); + for field in fields { + if self.is_secret(&field) { + message.clear_field(&field); + continue; + } + if !message.has_field(&field) { + continue; + } + omit_nested_secrets(self, message.get_field_mut(&field)); + } + } + + fn is_secret(&self, field: &FieldDescriptor) -> bool { + let Some(secret_option) = &self.secret_option else { + return false; + }; + matches!( + field.options().get_extension(secret_option).as_ref(), + ReflectValue::Bool(true) + ) + } + + fn pointer_intersects_secret( + &self, + descriptor: &MessageDescriptor, + segments: &[String], + ) -> bool { + if segments.is_empty() { + return self.message_contains_secret(descriptor, &mut HashSet::new()); + } + + let Some(field) = descriptor.get_field_by_json_name(&segments[0]) else { + return false; + }; + if self.is_secret(&field) { + return true; + } + + let remaining = &segments[1..]; + let Kind::Message(mut nested) = field.kind() else { + return false; + }; + + if field.is_map() { + let Some(value_field) = nested.get_field_by_name("value") else { + return false; + }; + let Kind::Message(value_message) = value_field.kind() else { + return false; + }; + nested = value_message; + if remaining.is_empty() { + return self.message_contains_secret(&nested, &mut HashSet::new()); + } + return self.pointer_intersects_secret(&nested, &remaining[1..]); + } + + if field.is_list() { + if remaining.is_empty() { + return self.message_contains_secret(&nested, &mut HashSet::new()); + } + return self.pointer_intersects_secret(&nested, &remaining[1..]); + } + + if remaining.is_empty() { + self.message_contains_secret(&nested, &mut HashSet::new()) + } else { + self.pointer_intersects_secret(&nested, remaining) + } + } + + fn message_contains_secret( + &self, + descriptor: &MessageDescriptor, + visiting: &mut HashSet, + ) -> bool { + if !visiting.insert(descriptor.full_name().to_string()) { + return false; + } + let contains = descriptor.fields().any(|field| { + if self.is_secret(&field) { + return true; + } + match field.kind() { + Kind::Message(message) if field.is_map() => message + .get_field_by_name("value") + .and_then(|value| match value.kind() { + Kind::Message(message) => Some(message), + _ => None, + }) + .is_some_and(|message| self.message_contains_secret(&message, visiting)), + Kind::Message(message) => self.message_contains_secret(&message, visiting), + _ => false, + } + }); + visiting.remove(descriptor.full_name()); + contains + } +} + +fn omit_nested_secrets(codec: &ProtoJsonCodec, value: &mut ReflectValue) { + match value { + ReflectValue::Message(message) => codec.omit_secrets(message), + ReflectValue::List(values) => { + for value in values { + omit_nested_secrets(codec, value); + } + } + ReflectValue::Map(values) => { + for value in values.values_mut() { + omit_nested_secrets(codec, value); + } + } + _ => {} + } +} + +fn parse_json_pointer(pointer: &str) -> Result> { + if pointer.is_empty() { + return Ok(Vec::new()); + } + let Some(pointer) = pointer.strip_prefix('/') else { + return Err(InterceptorError::InvalidResult(format!( + "invalid JSON patch path '{pointer}': JSON Pointer must start with '/'" + ))); + }; + pointer + .split('/') + .map(|segment| { + let mut decoded = String::with_capacity(segment.len()); + let mut chars = segment.chars(); + while let Some(ch) = chars.next() { + if ch != '~' { + decoded.push(ch); + continue; + } + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => { + return Err(InterceptorError::InvalidResult(format!( + "invalid JSON patch path '/{pointer}': invalid JSON Pointer escape" + ))); + } + } + } + Ok(decoded) + }) + .collect() } #[cfg(test)] @@ -93,7 +290,8 @@ mod tests { use std::collections::HashMap; use openshell_core::proto::{ - CreateSandboxRequest, GpuResourceRequirements, SandboxSpec, UpdateConfigRequest, + CreateProviderRequest, CreateSandboxRequest, GpuResourceRequirements, Provider, + SandboxSpec, UpdateConfigRequest, }; use prost::Message as _; use prost_types::{ @@ -131,6 +329,121 @@ mod tests { assert_eq!(decoded, request); } + #[test] + fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let request = CreateProviderRequest { + provider: Some(Provider { + r#type: "github".to_string(), + credentials: HashMap::from([( + "GITHUB_TOKEN".to_string(), + "secret-value".to_string(), + )]), + config: HashMap::from([("region".to_string(), "us-west".to_string())]), + ..Provider::default() + }), + }; + let encoded = request.encode_to_vec(); + + let authoritative = codec + .decode_bytes_to_json("openshell.v1.CreateProviderRequest", &encoded) + .unwrap(); + let interceptor = codec + .decode_bytes_to_interceptor_json("openshell.v1.CreateProviderRequest", &encoded) + .unwrap(); + + assert_eq!( + authoritative["provider"]["credentials"]["GITHUB_TOKEN"], + "secret-value" + ); + assert!(interceptor["provider"].get("credentials").is_none()); + assert_eq!(interceptor["provider"]["config"]["region"], "us-west"); + } + + #[test] + fn dedicated_secret_fields_are_annotated_in_the_descriptor_set() { + let codec = ProtoJsonCodec::openshell().unwrap(); + for (message_name, field_name) in [ + ("openshell.datamodel.v1.Provider", "credentials"), + ("openshell.inference.v1.ResolvedRoute", "api_key"), + ("openshell.compute.v1.DriverSandboxSpec", "sandbox_token"), + ("openshell.v1.IssueSandboxTokenResponse", "token"), + ("openshell.v1.RefreshSandboxTokenResponse", "token"), + ("openshell.v1.CreateSshSessionResponse", "token"), + ("openshell.v1.RevokeSshSessionRequest", "token"), + ("openshell.v1.TcpForwardInit", "authorization_token"), + ("openshell.v1.SshSession", "token"), + ( + "openshell.v1.StoredProviderCredentialRefreshState", + "material", + ), + ("openshell.v1.ConfigureProviderRefreshRequest", "material"), + ( + "openshell.v1.GetSandboxProviderEnvironmentResponse", + "environment", + ), + ] { + let message = codec.message_descriptor(message_name).unwrap(); + let field = message.get_field_by_name(field_name).unwrap(); + assert!( + codec.is_secret(&field), + "{message_name}.{field_name} must be marked secret" + ); + } + } + + #[test] + fn generic_sandbox_environment_remains_visible() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + ..SandboxSpec::default() + }), + ..CreateSandboxRequest::default() + }; + + let interceptor = codec + .decode_bytes_to_interceptor_json( + "openshell.v1.CreateSandboxRequest", + &request.encode_to_vec(), + ) + .unwrap(); + + assert_eq!(interceptor["spec"]["environment"]["FEATURE_FLAG"], "on"); + } + + #[test] + fn secret_patch_paths_and_containing_objects_are_hidden() { + let codec = ProtoJsonCodec::openshell().unwrap(); + for pointer in [ + "", + "/provider", + "/provider/credentials", + "/provider/credentials/key", + ] { + let err = codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + pointer, + ) + .expect_err("secret path or ancestor must be rejected"); + assert!(err.to_string().contains("omitted secret field")); + } + codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + "/provider/config/region", + ) + .unwrap(); + codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + "/provider/metadata/name", + ) + .unwrap(); + } + #[test] fn dynamic_update_config_round_trip_preserves_annotations() { let codec = diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index cb1c1c0e32..e5d55d2fee 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -214,7 +214,11 @@ impl GatewayInterceptorRuntime { let mut operation = operation; for plan in plans { - let result = evaluate_plan(plan, operation.json.clone(), context).await; + let interceptor_view = self + .codec + .decode_bytes_to_interceptor_json(operation_type, &operation.encoded) + .map_err(|err| Status::internal(err.to_string()))?; + let result = evaluate_plan(plan, interceptor_view, context).await; let result = match result { Ok(result) => result, Err(err) => { @@ -254,6 +258,10 @@ fn apply_evaluation_result( if plan.phase == Phase::ModifyOperation && !result.patches.is_empty() { let patch_count = result.patches.len(); + if let Err(err) = validate_patch_visibility(codec, operation_type, &result.patches) { + apply_failure_policy(plan, &err)?; + return Ok(operation); + } match operation.apply_patches(codec, operation_type, &result.patches) { Ok(candidate) => { emit_evaluation_metrics(plan, "allow", patch_count); @@ -272,6 +280,20 @@ fn apply_evaluation_result( } } +fn validate_patch_visibility( + codec: &ProtoJsonCodec, + operation_type: &str, + patches: &[JsonPatch], +) -> Result<()> { + for patch in patches { + codec.ensure_interceptor_patch_path_visible(operation_type, &patch.path)?; + if !patch.from.is_empty() { + codec.ensure_interceptor_patch_path_visible(operation_type, &patch.from)?; + } + } + Ok(()) +} + async fn evaluate_plan( plan: &BindingPlan, operation: Value, @@ -577,7 +599,8 @@ mod tests { use super::*; use openshell_core::proto::gateway_interceptor::v1::gateway_interceptor_client::GatewayInterceptorClient; use openshell_core::proto::{ - CreateSandboxRequest, SandboxSpec, SandboxTemplate, UpdateConfigRequest, + CreateProviderRequest, CreateSandboxRequest, Provider, SandboxSpec, SandboxTemplate, + UpdateConfigRequest, }; use serde_json::json; use std::collections::HashMap; @@ -724,6 +747,117 @@ mod tests { } } + fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation { + let request = CreateProviderRequest { + provider: Some(Provider { + r#type: "github".to_string(), + credentials: HashMap::from([( + "GITHUB_TOKEN".to_string(), + "secret-value".to_string(), + )]), + config: HashMap::from([("region".to_string(), "old".to_string())]), + ..Provider::default() + }), + }; + let json = codec + .decode_message_to_json("openshell.v1.CreateProviderRequest", &request) + .unwrap(); + ValidatedOperation::new(codec, "openshell.v1.CreateProviderRequest", json).unwrap() + } + + #[tokio::test] + async fn secret_patch_path_fails_closed() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![patch( + "replace", + "/provider/credentials/GITHUB_TOKEN", + json!("replacement"), + )]); + + let status = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .expect_err("secret patch must fail closed"); + + assert!(status.message().contains("omitted secret field")); + } + + #[tokio::test] + async fn secret_patch_path_fails_open_to_the_unchanged_operation() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let prior = operation.clone(); + let result = allowed_result(vec![patch( + "remove", + "/provider/credentials/GITHUB_TOKEN", + Value::Null, + )]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation, + ) + .unwrap(); + + assert_eq!(outcome, prior); + } + + #[tokio::test] + async fn secret_patch_from_path_is_rejected() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![JsonPatch { + op: "copy".to_string(), + path: "/provider/config/copied".to_string(), + value: None, + from: "/provider/credentials/GITHUB_TOKEN".to_string(), + }]); + + let status = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .expect_err("secret patch source must fail closed"); + + assert!(status.message().contains("omitted secret field")); + } + + #[tokio::test] + async fn non_secret_patch_preserves_authoritative_credentials() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![patch( + "replace", + "/provider/config/region", + json!("new"), + )]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .unwrap(); + let decoded = CreateProviderRequest::decode(outcome.encoded.as_slice()).unwrap(); + let provider = decoded.provider.unwrap(); + + assert_eq!(provider.config["region"], "new"); + assert_eq!(provider.credentials["GITHUB_TOKEN"], "secret-value"); + } + #[test] fn phase_evaluations_use_the_matching_oneof_variant() { let operation = json_to_struct(json!({"name": "demo"})).unwrap(); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index af4b6a5cc9..3b6dd2cc71 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -146,18 +146,19 @@ name = "quota" grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" order = 10 failure_policy = "fail_closed" +binding_policy = "allowlist" timeout = "500ms" max_response_bytes = 1048576 max_patches = 32 [[openshell.gateway.interceptors.bindings]] -id = "quota-create-sandbox" +rpc = "openshell.v1.OpenShell/CreateSandbox" phases = ["modify_operation", "validate"] failure_policy = "fail_closed" [[openshell.gateway.interceptors.bindings]] -rpc = "openshell.v1.OpenShell/UpdateConfig" -disabled = true +rpc = "openshell.v1.OpenShell/UpdateConfig" +phases = ["validate"] ``` 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. @@ -166,7 +167,15 @@ 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. +`[[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. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object. + +`binding_policy` controls how the manifest and operator binding configuration combine: + +- `dynamic` enables valid manifest bindings and treats configured entries as optional narrowing overrides. This is the compatibility default. The gateway logs a startup warning because the interceptor controls its non-secret RPC authority. +- `allowlist` enables only configured RPC selectors and phases. The gateway ignores and logs extra manifest declarations, but fails startup when the manifest omits a configured RPC or phase. +- `exact` requires the configured and manifest RPC selectors and phases to match exactly. + +Bindings under `allowlist` and `exact` require `rpc` or `service` plus `method`, and a nonempty `phases` list. They match by RPC rather than manifest binding ID. Duplicate selectors, `id`, and `disabled` are invalid in these modes; omit a binding to disable it. Binding failure policy comes from the binding, then the configured service, then defaults to `fail_closed`. Manifest failure policies do not override strict-mode operator configuration. Bindings that include `post_commit` must resolve to `failure_policy = "fail_open"`; the gateway rejects fail-closed post-commit configuration at startup. Post-commit evaluation is observational. If response observation or interceptor evaluation fails after a handler succeeds, the gateway logs and counts the failure but returns the committed response unchanged. @@ -183,7 +192,7 @@ provider_profile_sources = [ The gateway validates snapshot structure and provider-profile semantics. It treats a configured interceptor as the source trust boundary and does not verify signature, hash, or key annotations in profile payloads. -`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. +`failure_policy` accepts `fail_closed` or `fail_open`. `timeout` accepts `ms` and `s` suffixes. In `dynamic` mode, 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/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 032778ad5e..ad1e5d486b 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -102,9 +102,38 @@ name = "provider-governance" grpc_endpoint = "http://127.0.0.1:18081" order = 10 failure_policy = "fail_closed" +binding_policy = "allowlist" timeout = "500ms" max_response_bytes = 1048576 max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateSandbox" +phases = ["modify_operation", "validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateProvider" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/SubmitPolicyAnalysis" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/ImportProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/DeleteProviderProfile" +phases = ["validate"] ``` Run the launcher script to start a local gateway with the interceptor attached. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 8a906bc2a0..351d73fea5 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -345,9 +345,38 @@ name = "provider-governance" grpc_endpoint = "http://$INTERCEPTOR_ADDR" order = 10 failure_policy = "fail_closed" +binding_policy = "allowlist" timeout = "500ms" max_response_bytes = 1048576 max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateSandbox" +phases = ["modify_operation", "validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateProvider" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/SubmitPolicyAnalysis" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/ImportProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/DeleteProviderProfile" +phases = ["validate"] EOF } diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f471f575a0..3ef95afbbd 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.compute.v1; import "google/protobuf/struct.proto"; +import "options.proto"; // Internal compute-driver contract used by the gateway. // @@ -94,7 +95,7 @@ message DriverSandboxSpec { // the Kubernetes driver ignores this field and relies on its projected // ServiceAccount token bootstrap instead). Never echoed to the public // Sandbox proto. - string sandbox_token = 11; + string sandbox_token = 11 [(openshell.options.v1.secret) = true]; } message ResourceRequirements { diff --git a/proto/datamodel.proto b/proto/datamodel.proto index cfd6e63c45..ada4716b11 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -5,6 +5,8 @@ syntax = "proto3"; package openshell.datamodel.v1; +import "options.proto"; + // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // // This structure provides consistent metadata (identity, labels, annotations, @@ -40,7 +42,7 @@ message Provider { // Canonical provider type slug (for example: "claude", "gitlab"). string type = 2; // Secret values used for authentication. - map credentials = 3; + map credentials = 3 [(openshell.options.v1.secret) = true]; // Non-secret provider configuration. map config = 4; // Expiration timestamps for credential values, keyed by credential/env var diff --git a/proto/inference.proto b/proto/inference.proto index b0bc581e81..e982a2700e 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.inference.v1; import "datamodel.proto"; +import "options.proto"; // Inference service provides cluster inference configuration and bundle delivery. service Inference { @@ -104,7 +105,7 @@ message ResolvedRoute { string name = 1; string base_url = 2; repeated string protocols = 3; - string api_key = 4; + string api_key = 4 [(openshell.options.v1.secret) = true]; string model_id = 5; string provider_type = 6; // Per-route request timeout in seconds. 0 means use default (60s). diff --git a/proto/openshell.proto b/proto/openshell.proto index 29cb343d5b..17c086418f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -7,6 +7,7 @@ package openshell.v1; import "datamodel.proto"; import "google/protobuf/struct.proto"; +import "options.proto"; import "sandbox.proto"; // OpenShell service provides sandbox, provider, and runtime management capabilities. @@ -256,7 +257,7 @@ message IssueSandboxTokenRequest {} // gateway RPC. message IssueSandboxTokenResponse { // Gateway-minted JWT bound to the calling sandbox's UUID. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; // Absolute expiry of the issued token, milliseconds since the epoch. 0 means // the token is non-expiring. int64 expires_at_ms = 2; @@ -271,7 +272,7 @@ message RefreshSandboxTokenRequest {} // in-memory bearer credential. message RefreshSandboxTokenResponse { // Fresh gateway-minted JWT bound to the same sandbox UUID. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; // Absolute expiry of the new token, milliseconds since the epoch. 0 means // the token is non-expiring. int64 expires_at_ms = 2; @@ -557,7 +558,7 @@ message CreateSshSessionResponse { // Session token for the gateway tunnel. URL-safe ASCII // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or // whitespace. - string token = 2; + string token = 2 [(openshell.options.v1.secret) = true]; // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus @@ -651,7 +652,7 @@ message ServiceEndpointResponse { // Revoke SSH session request. message RevokeSshSessionRequest { // Session token to revoke. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; } // Revoke SSH session response. @@ -727,7 +728,7 @@ message TcpForwardInit { } // Optional target-specific authorization token. SSH targets use this as the // short-lived SSH session token issued by CreateSshSession. - string authorization_token = 7; + string authorization_token = 7 [(openshell.options.v1.secret) = true]; } // A single frame on the CLI-to-gateway TCP forward stream. @@ -766,7 +767,7 @@ message SshSession { string sandbox_id = 2; // Session token. - string token = 3; + string token = 3 [(openshell.options.v1.secret) = true]; // Expiry timestamp in milliseconds since epoch. 0 means no expiry // (backward-compatible default for sessions created before this field existed). @@ -1018,7 +1019,7 @@ message StoredProviderCredentialRefreshState { string provider_name = 3; string credential_key = 4; ProviderCredentialRefreshStrategy strategy = 5; - map material = 6; + map material = 6 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 7; int64 expires_at_ms = 8; int64 next_refresh_at_ms = 9; @@ -1044,7 +1045,7 @@ message ConfigureProviderRefreshRequest { string provider = 1; string credential_key = 2; ProviderCredentialRefreshStrategy strategy = 3; - map material = 4; + map material = 4 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; } @@ -1184,7 +1185,7 @@ message GetSandboxProviderEnvironmentRequest { // Get sandbox provider environment response. message GetSandboxProviderEnvironmentResponse { // Provider credential environment variables. - map environment = 1; + map environment = 1 [(openshell.options.v1.secret) = true]; // Fingerprint for the provider credential inputs that produced environment. uint64 provider_env_revision = 2; // Expiration timestamps for returned environment variables. diff --git a/proto/options.proto b/proto/options.proto new file mode 100644 index 0000000000..ca0764cc3f --- /dev/null +++ b/proto/options.proto @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.options.v1; + +import "google/protobuf/descriptor.proto"; + +// Marks a protobuf field whose value must not cross generic observation or +// extension boundaries such as gateway interceptors. +extend google.protobuf.FieldOptions { + bool secret = 50001; +} diff --git a/tasks/python.toml b/tasks/python.toml index b95d966714..62b8b4f787 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -230,6 +230,7 @@ uv run python -m grpc_tools.protoc \ proto/inference.proto \ proto/openshell.proto \ proto/datamodel.proto \ + proto/options.proto \ proto/sandbox.proto # Fix absolute imports in generated stubs to use package-relative imports uv run python - <<'PY' @@ -239,6 +240,7 @@ import re line_rewrites = { "python/openshell/_proto/inference_pb2.py": [ (r"^import datamodel_pb2 as datamodel__pb2$", "from . import datamodel_pb2 as datamodel__pb2"), + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), ], "python/openshell/_proto/inference_pb2_grpc.py": [ (r"^import inference_pb2 as inference__pb2$", "from . import inference_pb2 as inference__pb2"), @@ -249,9 +251,11 @@ line_rewrites = { ], "python/openshell/_proto/openshell_pb2.py": [ (r"^import datamodel_pb2 as datamodel__pb2$", "from . import datamodel_pb2 as datamodel__pb2"), + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), (r"^import sandbox_pb2 as sandbox__pb2$", "from . import sandbox_pb2 as sandbox__pb2"), ], "python/openshell/_proto/datamodel_pb2.py": [ + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), (r"^import sandbox_pb2 as sandbox__pb2$", "from . import sandbox_pb2 as sandbox__pb2"), ], "python/openshell/_proto/datamodel_pb2_grpc.py": [ From 5c8545b745c244c796caea1b037af12ec3cd859e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 15:39:17 -0700 Subject: [PATCH 24/27] fix(gateway): snapshot provider profiles per request Signed-off-by: Drew Newberry --- architecture/gateway.md | 9 + crates/openshell-server/src/grpc/policy.rs | 278 ++++++++++++++-- crates/openshell-server/src/grpc/provider.rs | 314 ++++++++---------- .../src/provider_profile_sources.rs | 293 +++++++++++----- 4 files changed, 591 insertions(+), 303 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index c688f9cc07..6d86bb1bbb 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -117,6 +117,15 @@ IDs fail instead of creating source precedence. The gateway treats configured interceptors as trusted sources and does not verify signature annotations in their profile payloads. +Each logical gateway request captures the selected sources into one validated, +immutable effective catalog before deriving provider behavior. Policy layers, +credential scope, injected environment material, dynamic token grants, and +provider-environment revisions use that same catalog. Each configured source is +therefore fetched at most once per request, and a source revision change becomes +visible on the next request instead of partway through the current request. The +capture emits debug diagnostics with the combined catalog revision, source fetch +count, and profile count; it never logs provider credentials or profile material. + Supported auth modes: | Mode | Use | diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 80cd995b29..a4add6763d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -14,6 +14,8 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; +use crate::provider_profile_sources::EffectiveProviderProfileCatalog; +#[cfg(test)] use crate::provider_profile_sources::ProviderProfileSources; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; @@ -520,7 +522,7 @@ fn run_prover_findings( /// with the merged policy the prover validates against. async fn build_credential_set_for_sandbox_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); @@ -538,8 +540,7 @@ async fn build_credential_set_for_sandbox_with_catalog( let provider_type = provider.r#type.trim(); let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = - super::provider::get_provider_type_profile_with_catalog(store, catalog, profile_id) - .await? + super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) else { warn!( provider_name = %name, @@ -978,6 +979,7 @@ async fn auto_approve_chunk( // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -1016,7 +1018,7 @@ async fn current_effective_policy_for_sandbox( .unwrap_or_default(); let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + catalog, &provider_names, ) .await?; @@ -1221,6 +1223,10 @@ pub(super) async fn handle_get_sandbox_config( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; // Try to get the latest policy from the policy history table. let latest = state @@ -1325,7 +1331,7 @@ pub(super) async fn handle_get_sandbox_config( { let provider_layers = profile_provider_policy_layers_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + &provider_profile_catalog, &sandbox_provider_names, ) .await?; @@ -1340,7 +1346,7 @@ pub(super) async fn handle_get_sandbox_config( let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + &provider_profile_catalog, &sandbox_provider_names, ) .await?; @@ -1362,17 +1368,15 @@ async fn compute_provider_env_revision( store: &Store, provider_names: &[String], ) -> Result { - compute_provider_env_revision_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider_names, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + compute_provider_env_revision_with_catalog(store, &catalog, provider_names).await } pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1394,8 +1398,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, catalog, &provider.r#type, &mut hasher) - .await?; + hash_provider_profile_revision(catalog, &provider.r#type, &mut hasher); let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1421,16 +1424,13 @@ pub(super) async fn compute_provider_env_revision_with_catalog( )?)) } -async fn hash_provider_profile_revision( - store: &Store, - catalog: &ProviderProfileSources, +fn hash_provider_profile_revision( + catalog: &EffectiveProviderProfileCatalog, provider_type: &str, hasher: &mut Sha256, -) -> Result<(), Status> { +) { let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog - .hash_profile_revision(store, profile_id, hasher) - .await + catalog.hash_profile_revision(profile_id, hasher); } #[cfg(test)] @@ -1438,17 +1438,15 @@ async fn profile_provider_policy_layers( store: &Store, provider_names: &[String], ) -> Result, Status> { - profile_provider_policy_layers_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider_names, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + profile_provider_policy_layers_with_catalog(store, &catalog, provider_names).await } async fn profile_provider_policy_layers_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); @@ -1463,8 +1461,7 @@ async fn profile_provider_policy_layers_with_catalog( let provider_type = provider.r#type.trim(); let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = - super::provider::get_provider_type_profile_with_catalog(store, catalog, profile_id) - .await? + super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) else { warn!( provider_name = %name, @@ -1526,15 +1523,19 @@ pub(super) async fn handle_get_sandbox_provider_environment( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_names = spec.providers; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; let provider_env_revision = compute_provider_env_revision_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + &provider_profile_catalog, &provider_names, ) .await?; let provider_environment = super::provider::resolve_provider_environment_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + &provider_profile_catalog, &provider_names, ) .await?; @@ -2450,7 +2451,17 @@ pub(super) async fn handle_submit_policy_analysis( // case for the common single-chunk submission shape. If real workloads // surface a problem with batches that interact across chunks, the right // fix is to recompute baseline after each successful auto-approve. - let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let current_policy = current_effective_policy_for_sandbox( + state, + &provider_profile_catalog, + &sandbox, + &sandbox_id, + ) + .await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2471,7 +2482,7 @@ pub(super) async fn handle_submit_policy_analysis( .unwrap_or_default(); let credential_set = build_credential_set_for_sandbox_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, + &provider_profile_catalog, &provider_names_for_creds, ) .await?; @@ -4165,6 +4176,7 @@ mod tests { use crate::persistence::test_store; use std::collections::HashMap; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; /// Wrap a request with a user `Principal` so handler scope guards treat @@ -4701,6 +4713,202 @@ mod tests { .expect("sandbox config should include policy") } + #[tokio::test] + async fn request_paths_use_one_provider_profile_snapshot() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, + }; + + fn snapshot_profile( + id: &str, + host: &str, + credential_env: &str, + token_endpoint: &str, + ) -> ProviderProfile { + ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec![credential_env.to_string()], + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: token_endpoint.to_string(), + audience: "api://snapshot-test".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + } + } + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let state = test_server_state().await; + let mut state = Arc::into_inner(state).expect("test state should be uniquely owned"); + state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ( + "revision-a".to_string(), + vec![ + snapshot_profile( + "moving-a", + "one.revision-a.example", + "TOKEN_A", + "https://auth.revision-a.example/token-a", + ), + snapshot_profile( + "moving-b", + "two.revision-a.example", + "TOKEN_B", + "https://auth.revision-a.example/token-b", + ), + ], + ), + ( + "revision-b".to_string(), + vec![ + snapshot_profile( + "moving-a", + "one.revision-b.example", + "TOKEN_A", + "https://auth.revision-b.example/token-a", + ), + snapshot_profile( + "moving-b", + "two.revision-b.example", + "TOKEN_B", + "https://auth.revision-b.example/token-b", + ), + ], + ), + ], + Arc::clone(&fetch_count), + ); + let state = Arc::new(state); + enable_providers_v2(&state).await; + + let mut provider_a = test_provider("provider-a", "moving-a"); + provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]); + let mut provider_b = test_provider("provider-b", "moving-b"); + provider_b.credentials = HashMap::from([("TOKEN_B".to_string(), "b".to_string())]); + state.store.put_message(&provider_a).await.unwrap(); + state.store.put_message(&provider_b).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-snapshot-consistency", + "snapshot-consistency", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["provider-a".to_string(), "provider-b".to_string()], + )) + .await + .unwrap(); + + let first = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + let first_policy = first.policy.unwrap(); + assert!(first_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-a.example") + })); + assert!(first_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "two.revision-a.example") + })); + + let second = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 2); + assert_ne!(first.provider_env_revision, second.provider_env_revision); + let second_policy = second.policy.unwrap(); + assert!(second_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-b.example") + })); + assert!(!second_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-a.example") + })); + + let environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 3); + assert_eq!( + environment.provider_env_revision, + second.provider_env_revision + ); + assert_eq!(environment.dynamic_credentials.len(), 2); + assert!(environment.dynamic_credentials.values().all(|credential| { + credential + .token_grant + .as_ref() + .is_some_and(|grant| grant.token_endpoint.contains("revision-b")) + })); + + handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "snapshot-consistency".to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "snapshot_consistency_test".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "snapshot_consistency_test".to_string(), + endpoints: vec![NetworkEndpoint { + host: "proposal.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 4); + } + #[tokio::test] async fn provider_policy_layers_skip_unknown_provider_types() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 928b68bc44..aa4b3ab195 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -9,8 +9,8 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; use crate::provider_profile_sources::{ - ProviderProfileSources, profile_response_payload, profile_storage_payload, - stored_profile_resource_version, stored_provider_profile, + EffectiveProviderProfileCatalog, ProviderProfileSources, profile_response_payload, + profile_storage_payload, stored_profile_resource_version, stored_provider_profile, }; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, @@ -72,17 +72,15 @@ pub(super) async fn create_provider_record( store: &Store, provider: Provider, ) -> Result { - create_provider_record_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + create_provider_record_with_catalog(store, &catalog, provider).await } pub(super) async fn create_provider_record_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -117,7 +115,7 @@ pub(super) async fn create_provider_record_with_catalog( return Err(Status::invalid_argument("provider.type is required")); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, catalog, &provider.r#type).await? + && !provider_type_allows_empty_credentials(catalog, &provider.r#type) { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -197,17 +195,15 @@ pub(super) async fn update_provider_record( store: &Store, provider: Provider, ) -> Result { - update_provider_record_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + update_provider_record_with_catalog(store, &catalog, provider).await } pub(super) async fn update_provider_record_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -470,17 +466,15 @@ pub(super) async fn resolve_provider_environment( store: &Store, provider_names: &[String], ) -> Result { - resolve_provider_environment_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider_names, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + resolve_provider_environment_with_catalog(store, &catalog, provider_names).await } pub(super) async fn resolve_provider_environment_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result { if provider_names.is_empty() { @@ -560,7 +554,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( /// host, port, endpoint path, and provider credential identity. pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -582,9 +576,7 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = - get_provider_type_profile_with_catalog(store, catalog, profile_id).await? - else { + let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { continue; }; @@ -875,17 +867,15 @@ pub async fn validate_provider_environment_keys_unique( store: &Store, provider_names: &[String], ) -> Result<(), Status> { - validate_provider_environment_keys_unique_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider_names, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + validate_provider_environment_keys_unique_with_catalog(store, &catalog, provider_names).await } pub async fn validate_provider_environment_keys_unique_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( @@ -900,7 +890,7 @@ pub async fn validate_provider_environment_keys_unique_with_catalog( pub async fn validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -918,17 +908,16 @@ pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, provider: &Provider, ) -> Result<(), Status> { - validate_provider_update_against_attached_sandboxes_with_catalog( - store, - &ProviderProfileSources::with_default_sources(), - provider, - ) - .await + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + validate_provider_update_against_attached_sandboxes_with_catalog(store, &catalog, provider) + .await } pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); @@ -957,7 +946,7 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( async fn validate_provider_environment_keys_unique_at( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -987,10 +976,9 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend( - dynamic_token_grant_bindings_for_provider_with_catalog(store, catalog, &provider) - .await?, - ); + dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, &provider, + )); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -1006,21 +994,16 @@ struct DynamicTokenGrantBinding { score: u32, } -async fn dynamic_token_grant_bindings_for_provider_with_catalog( - store: &Store, - catalog: &ProviderProfileSources, +fn dynamic_token_grant_bindings_for_provider_with_catalog( + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, -) -> Result, Status> { +) -> Vec { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile_with_catalog(store, catalog, profile_id).await? - else { - return Ok(Vec::new()); + let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { + return Vec::new(); }; - Ok(dynamic_token_grant_bindings_for_profile( - &provider_name, - &profile.to_proto(), - )) + dynamic_token_grant_bindings_for_profile(&provider_name, &profile.to_proto()) } fn dynamic_token_grant_bindings_for_profile( @@ -1254,12 +1237,12 @@ pub(super) async fn handle_create_provider( return Err(Status::invalid_argument("provider is required")); }; let provider_type = provider.r#type.clone(); - let result = create_provider_record_with_catalog( - state.store.as_ref(), - &state.provider_profile_sources, - provider, - ) - .await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let result = + create_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1312,10 +1295,12 @@ pub(super) async fn handle_list_provider_profiles( let request = request.into_inner(); let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let profiles = state + let catalog = state .provider_profile_sources - .list_profiles(state.store.as_ref()) - .await? + .snapshot_catalog(state.store.as_ref()) + .await?; + let profiles = catalog + .list_profiles() .into_iter() .skip(offset) .take(limit) @@ -1330,10 +1315,12 @@ pub(super) async fn handle_get_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - let profile = state + let catalog = state .provider_profile_sources - .get_profile(state.store.as_ref(), &id) - .await? + .snapshot_catalog(state.store.as_ref()) + .await?; + let profile = catalog + .get_profile(&id) .ok_or_else(|| Status::not_found("provider profile not found"))?; Ok(Response::new(ProviderProfileResponse { @@ -1349,20 +1336,18 @@ pub(super) async fn handle_import_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - diagnostics.extend( - profile_conflict_diagnostics( - state.store.as_ref(), - &state.provider_profile_sources, - &profiles, - ) - .await?, - ); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &state.provider_profile_sources, + &catalog, &profiles, "import", ) @@ -1419,14 +1404,14 @@ pub(super) async fn handle_update_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; diagnostics.extend( - profile_update_target_diagnostics( - state.store.as_ref(), - &state.provider_profile_sources, - &profiles, - &target_id, - ) - .await?, + profile_update_target_diagnostics(state.store.as_ref(), &catalog, &profiles, &target_id) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1447,16 +1432,11 @@ pub(super) async fn handle_update_provider_profiles( severity: "error".to_string(), }); } - let _sandbox_sync_guard = if has_errors(&diagnostics) { - None - } else { - Some(state.compute.sandbox_sync_guard().await) - }; if !has_errors(&diagnostics) { diagnostics.extend( profile_attached_sandbox_diagnostics( state.store.as_ref(), - &state.provider_profile_sources, + &catalog, &profiles, "update", ) @@ -1534,14 +1514,12 @@ pub(super) async fn handle_lint_provider_profiles( let request = request.into_inner(); let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend( - profile_conflict_diagnostics( - state.store.as_ref(), - &state.provider_profile_sources, - &profiles, - ) - .await?, - ); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1557,17 +1535,17 @@ pub(super) async fn handle_delete_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - if let Some(source_id) = state + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let catalog = state .provider_profile_sources - .static_source_for_profile(state.store.as_ref(), &id) - .await? - { + .snapshot_catalog(state.store.as_ref()) + .await?; + if let Some(source_id) = catalog.static_source_for_profile(&id) { return Err(Status::failed_precondition(format!( "provider profile '{id}' is managed by source '{source_id}' and cannot be deleted" ))); } - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let existing = state .store .get_message_by_name::(&id) @@ -1594,29 +1572,20 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) async fn get_provider_type_profile_with_catalog( - store: &Store, - catalog: &ProviderProfileSources, +pub(super) fn get_provider_type_profile_with_catalog( + catalog: &EffectiveProviderProfileCatalog, id: &str, -) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - catalog.get_type_profile(store, &id).await +) -> Option { + catalog.get_type_profile(id) } -async fn provider_refresh_defaults( - store: &Store, - catalog: &ProviderProfileSources, +fn provider_refresh_defaults( + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, credential_key: &str, -) -> Result, Status> { - let Some(profile) = - get_provider_type_profile_with_catalog(store, catalog, &provider.r#type).await? - else { - return Ok(None); - }; - Ok(profile +) -> Option { + let profile = get_provider_type_profile_with_catalog(catalog, &provider.r#type)?; + profile .credentials .iter() .find(|credential| { @@ -1626,7 +1595,7 @@ async fn provider_refresh_defaults( .iter() .any(|env_var| env_var == credential_key) }) - .and_then(|credential| credential.refresh.clone())) + .and_then(|credential| credential.refresh.clone()) } fn validate_refresh_material( @@ -1654,17 +1623,14 @@ fn validate_refresh_material( Ok(()) } -async fn provider_type_allows_empty_credentials( - store: &Store, - catalog: &ProviderProfileSources, +fn provider_type_allows_empty_credentials( + catalog: &EffectiveProviderProfileCatalog, provider_type: &str, -) -> Result { - let Some(profile) = - get_provider_type_profile_with_catalog(store, catalog, provider_type).await? - else { - return Ok(false); +) -> bool { + let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { + return false; }; - Ok(profile.allows_empty_provider_credentials()) + profile.allows_empty_provider_credentials() } fn normalize_profile_id_request(id: &str) -> Result { @@ -1718,7 +1684,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1726,7 +1692,7 @@ async fn profile_conflict_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if let Some(source_id) = catalog.static_source_for_profile(store, &id).await? { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), @@ -1758,7 +1724,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1779,7 +1745,7 @@ async fn profile_update_target_diagnostics( }); } } - if let Some(source_id) = catalog.static_source_for_profile(store, target_id).await? { + if let Some(source_id) = catalog.static_source_for_profile(target_id) { diagnostics.push(ProfileValidationDiagnostic { source: target_id.to_string(), profile_id: target_id.to_string(), @@ -1809,7 +1775,7 @@ async fn profile_update_target_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if let Some(source_id) = catalog.static_source_for_profile(store, &id).await? { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), @@ -1826,7 +1792,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, - catalog: &ProviderProfileSources, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1877,12 +1843,9 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend( - dynamic_token_grant_bindings_for_provider_with_catalog( - store, catalog, &provider, - ) - .await?, - ); + bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, &provider, + )); } } @@ -1975,12 +1938,12 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let result = update_provider_record_with_catalog( - state.store.as_ref(), - &state.provider_profile_sources, - provider, - ) - .await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let result = + update_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2134,20 +2097,18 @@ pub(super) async fn handle_configure_provider_refresh( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), - &state.provider_profile_sources, - &provider, - credential_key, - ) - .await?; - let refresh_defaults = provider_refresh_defaults( - state.store.as_ref(), - &state.provider_profile_sources, + &catalog, &provider, credential_key, ) .await?; + let refresh_defaults = provider_refresh_defaults(&catalog, &provider, credential_key); validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2238,12 +2199,7 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, )]), }; - update_provider_record_with_catalog( - state.store.as_ref(), - &state.provider_profile_sources, - updated, - ) - .await?; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2322,6 +2278,10 @@ pub(super) async fn handle_delete_provider_refresh( .is_some_and(|expires_at_ms| *expires_at_ms == refresh_state.expires_at_ms) }); if refresh_owned_expiry { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2339,12 +2299,7 @@ pub(super) async fn handle_delete_provider_refresh( 0, )]), }; - update_provider_record_with_catalog( - state.store.as_ref(), - &state.provider_profile_sources, - updated, - ) - .await?; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -3910,13 +3865,14 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), }; - let provider = update_provider_record_with_catalog( - state.store.as_ref(), - &state.provider_profile_sources, - updated, - ) - .await - .unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await + .unwrap(); + let provider = update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated) + .await + .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, "REFRESH_TOKEN", diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 84e2d4323f..534f67bc61 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -20,6 +20,7 @@ use openshell_providers::{ use prost::Message as _; use sha2::{Digest, Sha256}; use tonic::Status; +use tracing::debug; use crate::persistence::{ObjectType, Store}; @@ -163,8 +164,10 @@ struct EffectiveProfileEntry { } #[derive(Debug, Clone)] -struct EffectiveProviderProfiles { +pub struct EffectiveProviderProfileCatalog { profiles: BTreeMap, + revision: String, + source_count: usize, } impl ProviderProfileSources { @@ -238,79 +241,103 @@ impl ProviderProfileSources { } } - pub async fn list_profiles(&self, store: &Store) -> Result, Status> { - let catalog = self.effective_profiles(store).await?; - Ok(catalog - .profiles - .values() - .map(|entry| entry.response.clone()) - .collect()) + #[cfg(test)] + pub(crate) fn from_test_snapshot_sequence( + snapshots: Vec<(String, Vec)>, + fetch_count: Arc, + ) -> Self { + assert!( + !snapshots.is_empty(), + "test snapshot sequence must not be empty" + ); + Self { + sources: vec![Arc::new(SequencedProviderProfileSource { + snapshots: snapshots + .into_iter() + .map(|(revision, profiles)| ProviderProfileSnapshot { revision, profiles }) + .collect(), + fetch_count, + })], + } } - pub async fn get_profile( + pub(crate) async fn snapshot_catalog( &self, store: &Store, - id: &str, - ) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - Ok(self - .effective_profiles(store) - .await? - .profiles - .get(&id) - .map(|entry| entry.response.clone())) + ) -> Result { + let snapshots = self.snapshots(store).await?; + let catalog = build_effective_profiles(snapshots)?; + debug!( + catalog_revision = %catalog.revision(), + source_fetch_count = catalog.source_count(), + profile_count = catalog.profiles.len(), + "captured provider profile catalog snapshot" + ); + Ok(catalog) } - pub async fn get_type_profile( + async fn snapshots( &self, store: &Store, - id: &str, - ) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - Ok(self - .effective_profiles(store) - .await? - .profiles - .get(&id) - .map(|entry| entry.profile.clone())) + ) -> Result, Status> { + let mut snapshots = Vec::with_capacity(self.sources.len()); + for source in &self.sources { + let snapshot = source.snapshot(store).await?; + snapshots.push(CollectedProviderProfileSnapshot { + source_id: source.source_id().to_string(), + revision: snapshot.revision, + profiles: snapshot.profiles, + user_managed: source.user_managed(), + allow_empty: source.allow_empty(), + }); + } + Ok(snapshots) } +} - pub async fn static_source_for_profile( - &self, - store: &Store, - id: &str, - ) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - Ok(self - .effective_profiles(store) - .await? - .profiles +impl EffectiveProviderProfileCatalog { + pub(crate) fn revision(&self) -> &str { + &self.revision + } + + pub(crate) fn source_count(&self) -> usize { + self.source_count + } + + pub(crate) fn list_profiles(&self) -> Vec { + self.profiles + .values() + .map(|entry| entry.response.clone()) + .collect() + } + + pub(crate) fn get_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles.get(&id).map(|entry| entry.response.clone()) + } + + pub(crate) fn get_type_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles.get(&id).map(|entry| entry.profile.clone()) + } + + pub(crate) fn static_source_for_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles .get(&id) .filter(|entry| !entry.user_managed) - .map(|entry| entry.source_id.clone())) + .map(|entry| entry.source_id.clone()) } - pub async fn hash_profile_revision( - &self, - store: &Store, - profile_id: &str, - hasher: &mut Sha256, - ) -> Result<(), Status> { + pub(crate) fn hash_profile_revision(&self, profile_id: &str, hasher: &mut Sha256) { let Some(profile_id) = normalize_profile_id(profile_id) else { hasher.update(b"invalid-profile-id"); - return Ok(()); + return; }; - let catalog = self.effective_profiles(store).await?; - let Some(entry) = catalog.profiles.get(&profile_id) else { + let Some(entry) = self.profiles.get(&profile_id) else { hasher.update(b"missing"); - return Ok(()); + return; }; hasher.update(b"provider-profile-source-entry"); @@ -323,38 +350,17 @@ impl ProviderProfileSources { }; hasher.update(ownership_tag); hasher.update(entry.response.encode_to_vec()); - Ok(()) - } - - async fn effective_profiles(&self, store: &Store) -> Result { - let snapshots = self.snapshots(store).await?; - build_effective_profiles(snapshots) - } - - async fn snapshots( - &self, - store: &Store, - ) -> Result, Status> { - let mut snapshots = Vec::with_capacity(self.sources.len()); - for source in &self.sources { - let snapshot = source.snapshot(store).await?; - snapshots.push(CollectedProviderProfileSnapshot { - source_id: source.source_id().to_string(), - revision: snapshot.revision, - profiles: snapshot.profiles, - user_managed: source.user_managed(), - allow_empty: source.allow_empty(), - }); - } - Ok(snapshots) } } fn build_effective_profiles( snapshots: Vec, -) -> Result { +) -> Result { let mut source_ids = BTreeSet::new(); let mut profiles: BTreeMap = BTreeMap::new(); + let source_count = snapshots.len(); + let mut catalog_hasher = Sha256::new(); + catalog_hasher.update(b"openshell-effective-provider-profile-catalog-v1"); for snapshot in snapshots { let source_id = snapshot.source_id.trim(); @@ -368,12 +374,23 @@ fn build_effective_profiles( "duplicate provider profile source id '{source_id}'" ))); } + let source_revision = snapshot.revision.trim(); + if source_revision.is_empty() { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' returned an empty revision" + ))); + } if snapshot.profiles.is_empty() && !snapshot.allow_empty { return Err(Status::failed_precondition(format!( "provider profile source '{source_id}' returned no profiles" ))); } + catalog_hasher.update((source_id.len() as u64).to_le_bytes()); + catalog_hasher.update(source_id.as_bytes()); + catalog_hasher.update((source_revision.len() as u64).to_le_bytes()); + catalog_hasher.update(source_revision.as_bytes()); + let source_profiles = snapshot .profiles .iter() @@ -410,7 +427,7 @@ fn build_effective_profiles( id, EffectiveProfileEntry { source_id: source_id.to_string(), - source_revision: snapshot.revision.clone(), + source_revision: source_revision.to_string(), user_managed: snapshot.user_managed, profile: ProviderTypeProfile::from_proto(&profile), response: profile, @@ -419,7 +436,11 @@ fn build_effective_profiles( } } - Ok(EffectiveProviderProfiles { profiles }) + Ok(EffectiveProviderProfileCatalog { + profiles, + revision: format!("sha256:{:x}", catalog_hasher.finalize()), + source_count, + }) } fn validate_source_profiles( @@ -532,6 +553,36 @@ impl ProviderProfileSource for StaticProviderProfileSource { } } +#[cfg(test)] +#[derive(Debug)] +struct SequencedProviderProfileSource { + snapshots: Vec, + fetch_count: Arc, +} + +#[cfg(test)] +#[async_trait] +impl ProviderProfileSource for SequencedProviderProfileSource { + fn source_id(&self) -> &str { + "sequenced" + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + let index = self + .fetch_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(self.snapshots[index.min(self.snapshots.len() - 1)].clone()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -541,6 +592,7 @@ mod tests { ProviderProfileSnapshot as ProtoProviderProfileSnapshot, ProviderProfileSnapshotRequest, gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; + use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio_stream::wrappers::TcpListenerStream; @@ -624,6 +676,61 @@ mod tests { profile.to_proto() } + #[tokio::test] + async fn captured_catalog_is_immutable_and_each_source_is_fetched_once() { + let mut revision_a = profile("moving-profile"); + revision_a.display_name = "revision-a".to_string(); + let mut revision_b = revision_a.clone(); + revision_b.display_name = "revision-b".to_string(); + let fetch_count = Arc::new(AtomicUsize::new(0)); + let sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ("revision-a".to_string(), vec![revision_a]), + ("revision-b".to_string(), vec![revision_b]), + ], + Arc::clone(&fetch_count), + ); + let store = crate::persistence::test_store().await; + + let first = sources.snapshot_catalog(&store).await.unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + assert_eq!(first.source_count(), 1); + assert_eq!( + first.get_profile("moving-profile").unwrap().display_name, + "revision-a" + ); + assert!(first.get_type_profile("moving-profile").is_some()); + let mut first_profile_hash = Sha256::new(); + first.hash_profile_revision("moving-profile", &mut first_profile_hash); + let first_profile_hash = first_profile_hash.finalize(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + + let second = sources.snapshot_catalog(&store).await.unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 2); + assert_eq!( + second.get_profile("moving-profile").unwrap().display_name, + "revision-b" + ); + let mut second_profile_hash = Sha256::new(); + second.hash_profile_revision("moving-profile", &mut second_profile_hash); + assert_ne!(first_profile_hash, second_profile_hash.finalize()); + assert_ne!(first.revision(), second.revision()); + } + + #[test] + fn empty_source_revision_is_invalid() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "source-a".to_string(), + revision: " ".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!(err.message().contains("returned an empty revision")); + } + #[test] fn duplicate_profile_ids_across_sources_are_invalid() { let err = build_effective_profiles(vec![ @@ -756,7 +863,11 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let profiles = sources.list_profiles(&store).await.unwrap(); + let profiles = sources + .snapshot_catalog(&store) + .await + .unwrap() + .list_profiles(); assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].id, "github"); let snapshot = runtime @@ -788,7 +899,7 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.list_profiles(&store).await.unwrap_err(); + let err = sources.snapshot_catalog(&store).await.unwrap_err(); assert!(err.message().contains("returned no profiles")); task.abort(); } @@ -812,7 +923,7 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.list_profiles(&store).await.unwrap_err(); + let err = sources.snapshot_catalog(&store).await.unwrap_err(); assert!(err.message().contains("is invalid")); task.abort(); } @@ -839,7 +950,11 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let profiles = sources.list_profiles(&store).await.unwrap(); + let profiles = sources + .snapshot_catalog(&store) + .await + .unwrap() + .list_profiles(); assert!(profiles.iter().any(|profile| profile.id == "github")); assert!( profiles @@ -871,7 +986,7 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.list_profiles(&store).await.unwrap_err(); + let err = sources.snapshot_catalog(&store).await.unwrap_err(); assert!( err.message() .contains("duplicate provider profile id 'github'") @@ -898,7 +1013,7 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.list_profiles(&store).await.unwrap_err(); + let err = sources.snapshot_catalog(&store).await.unwrap_err(); assert!(err.message().contains("duplicate provider profile id")); task.abort(); } @@ -925,7 +1040,7 @@ mod tests { .unwrap(); let store = crate::persistence::test_store().await; - let err = sources.list_profiles(&store).await.unwrap_err(); + let err = sources.snapshot_catalog(&store).await.unwrap_err(); assert_eq!(err.code(), tonic::Code::Unavailable); task.abort(); } From af4710c07b58cc6115b49da91c9cd1c05b8aa2c8 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 16:04:52 -0700 Subject: [PATCH 25/27] chore(gateway): resolve server clippy warnings Signed-off-by: Drew Newberry --- crates/openshell-server/src/persistence/postgres.rs | 2 +- crates/openshell-server/src/persistence/sqlite.rs | 2 +- crates/openshell-server/src/policy_store.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index d63b159ab6..e3f9e5a7b3 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -451,7 +451,7 @@ LIMIT $3 OFFSET $4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, - provenance: Default::default(), + provenance: std::collections::HashMap::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 45a1d05465..0b1f50df6a 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -472,7 +472,7 @@ LIMIT ?3 OFFSET ?4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, - provenance: Default::default(), + provenance: std::collections::HashMap::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 70b60ee88a..7e3b7856a4 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -24,7 +24,7 @@ pub struct AtomicPolicyRevisionWrite { pub backfill_policy: Option, } -pub(crate) fn policy_record_for_atomic_write( +pub fn policy_record_for_atomic_write( write: &AtomicPolicyRevisionWrite, created_at_ms: i64, ) -> PolicyRecord { @@ -42,7 +42,7 @@ pub(crate) fn policy_record_for_atomic_write( } } -pub(crate) fn project_policy_revision_onto_sandbox( +pub fn project_policy_revision_onto_sandbox( write: &AtomicPolicyRevisionWrite, payload: &[u8], current_resource_version: u64, From d94ba94225d42c55d453aa7de883b345f2665716 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 16:34:17 -0700 Subject: [PATCH 26/27] fix(server): satisfy provider source clippy lint Signed-off-by: Drew Newberry --- crates/openshell-server/src/provider_profile_sources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 534f67bc61..f6882172b3 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -563,7 +563,7 @@ struct SequencedProviderProfileSource { #[cfg(test)] #[async_trait] impl ProviderProfileSource for SequencedProviderProfileSource { - fn source_id(&self) -> &str { + fn source_id(&self) -> &'static str { "sequenced" } From b8c717ee3c1e54fe4215c4fc67650bded5f8e644 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 13 Jul 2026 16:46:42 -0700 Subject: [PATCH 27/27] fix(server): initialize policy test annotations Signed-off-by: Drew Newberry --- crates/openshell-server/src/grpc/policy.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index a4add6763d..6fccf1eade 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8515,6 +8515,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy {