From ec1e9a099f193d7fd474cf01148b90cfb49255ec Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 14:08:34 -0700 Subject: [PATCH 01/12] 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 34b24bf76..342e60447 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 c86773bb7..cc7f6e39e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3751,6 +3751,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" @@ -3876,6 +3895,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 d873b2a10..ceaf338ae 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 c66d32610..d673ade26 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -360,6 +360,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; @@ -523,6 +526,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 } @@ -584,6 +664,7 @@ impl Config { tls, oidc: None, auth: GatewayAuthConfig::default(), + gateway_interceptors: Vec::new(), mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, database_url: String::new(), @@ -687,6 +768,16 @@ impl Config { self } + /// Set configured gateway interceptors. + #[must_use] + pub fn with_gateway_interceptors(mut self, interceptors: I) -> Self + where + I: IntoIterator, + { + self.gateway_interceptors = interceptors.into_iter().collect(); + self + } + /// Return the effective gRPC rate limit, if fully configured and enabled. #[must_use] pub fn grpc_rate_limit(&self) -> Option<(u64, Duration)> { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 321296369..8830bce9a 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -38,8 +38,9 @@ pub mod telemetry; pub mod time; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, - TlsConfig, + ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, + GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e..2cd82ff76 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -79,7 +79,24 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod gateway_interceptor { + pub mod v1 { + include!(concat!( + env!("OUT_DIR"), + "/openshell.gateway_interceptor.v1.rs" + )); + } +} + pub use datamodel::v1::*; +pub use gateway_interceptor::v1::*; pub use inference::v1::*; pub use openshell::*; pub use sandbox::v1::*; diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml new file mode 100644 index 000000000..f38b055bb --- /dev/null +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-gateway-interceptors" +description = "Gateway interceptor framework for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +base64 = { workspace = true } +hyper-util = { workspace = true, features = ["client", "http1", "http2", "tokio"] } +json-patch = "1.4" +metrics = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tower = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs new file mode 100644 index 000000000..2460c499b --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -0,0 +1,1891 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway interceptor framework. +//! +//! The gateway integrates this crate once at the gRPC routing boundary. The +//! runtime uses the generated protobuf descriptor set to decode unary +//! `openshell.v1.OpenShell` request frames into protobuf-JSON-shaped values, +//! apply interceptor decisions, and re-encode the request before tonic reaches +//! the handler. Handler modules do not need per-method interceptor hooks. + +#![allow(clippy::result_large_err)] + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use hyper_util::rt::TokioIo; +use json_patch::{PatchOperation, patch}; +use metrics::{counter, histogram}; +use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayInterceptorPhaseConfig, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorResult, InterceptorSelector, JsonPatch, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost::Message as _; +use prost_types::{ + DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, + FileDescriptorSet, Struct, + field_descriptor_proto::{Label, Type}, +}; +use serde_json::{Map, Number, Value}; +use tokio::net::UnixStream; +use tonic::codegen::http::Uri; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Code, Request, Status}; +use tower::service_fn; +use tracing::{debug, info, warn}; + +pub mod routes; + +const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; +const DEFAULT_MAX_PATCHES: usize = 32; +const GRPC_HEADER_LEN: usize = 5; + +#[derive(Debug, thiserror::Error)] +pub enum InterceptorError { + #[error("invalid interceptor config: {0}")] + Config(String), + #[error("interceptor transport error: {0}")] + Transport(String), + #[error("invalid interceptor result: {0}")] + InvalidResult(String), + #[error("protobuf transcode error: {0}")] + Transcode(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + PreRequest, + ModifyOperation, + Validate, + PostCommit, +} + +impl Phase { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::PreRequest => "pre_request", + Self::ModifyOperation => "modify_operation", + Self::Validate => "validate", + Self::PostCommit => "post_commit", + } + } + + #[must_use] + pub const fn to_proto(self) -> GatewayInterceptorPhase { + match self { + Self::PreRequest => GatewayInterceptorPhase::PreRequest, + Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, + Self::Validate => GatewayInterceptorPhase::Validate, + Self::PostCommit => GatewayInterceptorPhase::PostCommit, + } + } +} + +impl TryFrom for Phase { + type Error = InterceptorError; + + fn try_from(value: GatewayInterceptorPhase) -> Result { + match value { + GatewayInterceptorPhase::PreRequest => Ok(Self::PreRequest), + GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), + GatewayInterceptorPhase::Validate => Ok(Self::Validate), + GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), + GatewayInterceptorPhase::Unspecified => Err(InterceptorError::Config( + "binding phase must not be unspecified".to_string(), + )), + } + } +} + +impl From for Phase { + fn from(value: GatewayInterceptorPhaseConfig) -> Self { + match value { + GatewayInterceptorPhaseConfig::PreRequest => Self::PreRequest, + GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, + GatewayInterceptorPhaseConfig::Validate => Self::Validate, + GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + FailClosed, + FailOpen, + Ignore, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + Self::Ignore => "ignore", + } + } +} + +impl From for FailurePolicy { + fn from(value: GatewayInterceptorFailurePolicy) -> Self { + match value { + GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, + GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, + GatewayInterceptorFailurePolicy::Ignore => Self::Ignore, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RpcSelector { + pub service: String, + pub method: String, +} + +impl RpcSelector { + #[must_use] + pub fn new(service: impl Into, method: impl Into) -> Self { + Self { + service: service.into(), + method: method.into(), + } + } + + #[must_use] + pub fn rpc(&self) -> String { + format!("{}/{}", self.service, self.method) + } + + #[must_use] + pub fn from_grpc_path(path: &str) -> Option { + let path = path.strip_prefix('/').unwrap_or(path); + let (service, method) = path.rsplit_once('/')?; + Some(Self::new(service, method)) + } +} + +#[derive(Clone)] +struct BindingPlan { + interceptor_name: String, + binding_id: String, + selector: RpcSelector, + phase: Phase, + failure_policy: FailurePolicy, + timeout: Duration, + max_response_bytes: usize, + max_patches: usize, + client: GatewayInterceptorClient, +} + +impl std::fmt::Debug for BindingPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BindingPlan") + .field("interceptor_name", &self.interceptor_name) + .field("binding_id", &self.binding_id) + .field("selector", &self.selector) + .field("phase", &self.phase) + .field("failure_policy", &self.failure_policy) + .field("timeout", &self.timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("max_patches", &self.max_patches) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub struct GatewayInterceptorRuntime { + bindings: Arc>>, + routes: Arc, + descriptors: Arc, +} + +#[derive(Debug, Clone)] +pub struct EvaluationContext { + pub principal: BTreeMap, + pub current_state: Option, +} + +#[derive(Debug, Clone)] +pub struct InterceptedRequest { + pub body: Vec, + selector: RpcSelector, + operation: Value, +} + +/// Return `None` when no interceptors are configured. +pub async fn initialize( + configs: Vec, +) -> Result> { + if configs.is_empty() { + return Ok(None); + } + let runtime = GatewayInterceptorRuntime::build(configs).await?; + Ok(Some(runtime)) +} + +impl GatewayInterceptorRuntime { + async fn build(mut configs: Vec) -> Result { + configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); + + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET)?; + let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); + + for config in configs { + validate_service_config(&config)?; + let channel = connect_endpoint(&config.grpc_endpoint).await?; + let mut client = GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size( + config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), + ); + let manifest = client + .describe(Request::new(DescribeRequest {})) + .await + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); + let service_default = config + .failure_policy + .map(FailurePolicy::from) + .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) + .unwrap_or(FailurePolicy::FailClosed); + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; + let max_response_bytes = config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + let max_patches = config.max_patches.unwrap_or(DEFAULT_MAX_PATCHES); + + let override_index = OverrideIndex::new(&config.bindings)?; + for manifest_binding in &manifest.bindings { + let normalized = normalize_binding( + &config.name, + manifest_binding, + service_default, + &override_index, + )?; + let Some(normalized) = normalized else { + continue; + }; + if !routes + .is_interceptable(&normalized.selector.service, &normalized.selector.method) + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' targets non-interceptable RPC '{}'", + config.name, + normalized.binding_id, + normalized.selector.rpc() + ))); + } + for phase in normalized.phases { + if normalized.failure_policy == FailurePolicy::Ignore + && phase != Phase::PostCommit + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' uses failure_policy=ignore outside post_commit", + config.name, normalized.binding_id + ))); + } + let plan = BindingPlan { + interceptor_name: config.name.clone(), + binding_id: normalized.binding_id.clone(), + selector: normalized.selector.clone(), + phase, + failure_policy: normalized.failure_policy, + timeout, + max_response_bytes, + max_patches, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }; + bindings + .entry((normalized.selector.clone(), phase)) + .or_default() + .push(plan); + } + } + } + + let count: usize = bindings.values().map(Vec::len).sum(); + info!(bindings = count, "gateway interceptors initialized"); + Ok(Self { + bindings: Arc::new(bindings), + routes: Arc::new(routes), + descriptors: Arc::new(descriptors), + }) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.bindings.is_empty() + } + + #[must_use] + pub fn should_intercept_path(&self, path: &str) -> bool { + let Some(selector) = RpcSelector::from_grpc_path(path) else { + return false; + }; + self.routes + .is_interceptable(&selector.service, &selector.method) + && [ + Phase::PreRequest, + Phase::ModifyOperation, + Phase::Validate, + Phase::PostCommit, + ] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + } + + pub async fn evaluate_request( + &self, + path: &str, + body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result { + let selector = RpcSelector::from_grpc_path(path) + .ok_or_else(|| Status::invalid_argument("invalid gRPC method path"))?; + let input_type = self + .routes + .input_type(&selector.service, &selector.method) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? + .to_string(); + let frame = GrpcFrame::decode(body)?; + let mut operation = self + .descriptors + .decode_message_to_json(&input_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + operation = self + .evaluate_phase(&selector, Phase::PreRequest, operation, context) + .await?; + operation = self + .evaluate_phase(&selector, Phase::ModifyOperation, operation, context) + .await?; + operation = self + .evaluate_phase(&selector, Phase::Validate, operation, context) + .await?; + + let message = self + .descriptors + .encode_json_to_message(&input_type, &operation) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let body = GrpcFrame { + compressed: false, + message, + } + .encode() + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + Ok(InterceptedRequest { + body, + selector, + operation, + }) + } + + pub async fn evaluate_post_commit( + &self, + intercepted: &InterceptedRequest, + context: &EvaluationContext, + ) -> std::result::Result<(), Status> { + self.evaluate_phase( + &intercepted.selector, + Phase::PostCommit, + intercepted.operation.clone(), + context, + ) + .await + .map(|_| ()) + } + + async fn evaluate_phase( + &self, + selector: &RpcSelector, + phase: Phase, + operation: Value, + context: &EvaluationContext, + ) -> std::result::Result { + let Some(plans) = self.bindings.get(&(selector.clone(), phase)) else { + return Ok(operation); + }; + + let mut operation = operation; + for plan in plans { + let result = evaluate_plan(plan, operation.clone(), context).await; + let result = match result { + Ok(result) => result, + Err(err) => { + apply_failure_policy(plan, &err)?; + continue; + } + }; + + if let Err(err) = validate_result_contract(plan, &result) { + apply_failure_policy(plan, &err)?; + continue; + } + + if !result.allowed { + let reason = if result.reason.trim().is_empty() { + "operation denied by gateway interceptor".to_string() + } else { + result.reason.clone() + }; + emit_evaluation_metrics(plan, "deny", 0); + return Err(status_from_result(&result, reason)); + } + + if phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + let patch_ops = json_patch_operations(&result.patches) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + patch(&mut operation, &patch_ops).map_err(|err| { + Status::invalid_argument(format!("invalid JSON patch: {err}")) + })?; + emit_evaluation_metrics(plan, "allow", patch_count); + } else { + emit_evaluation_metrics(plan, "allow", 0); + } + } + Ok(operation) + } +} + +#[derive(Debug, Clone)] +struct NormalizedBinding { + binding_id: String, + selector: RpcSelector, + phases: Vec, + failure_policy: FailurePolicy, +} + +#[derive(Debug)] +struct OverrideIndex<'a> { + by_id: HashMap<&'a str, &'a GatewayInterceptorBindingOverride>, + by_selector: HashMap, +} + +impl<'a> OverrideIndex<'a> { + fn new(overrides: &'a [GatewayInterceptorBindingOverride]) -> Result { + let mut by_id = HashMap::new(); + let mut by_selector = HashMap::new(); + for override_cfg in overrides { + if let Some(id) = override_cfg.id.as_deref() + && by_id.insert(id, override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override id '{id}'" + ))); + } + if let Some(selector) = override_selector(override_cfg)? + && by_selector.insert(selector.rpc(), override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override selector '{}'", + selector.rpc() + ))); + } + } + Ok(Self { by_id, by_selector }) + } + + fn get( + &self, + binding_id: &str, + selector: &RpcSelector, + ) -> Option<&'a GatewayInterceptorBindingOverride> { + self.by_id + .get(binding_id) + .or_else(|| self.by_selector.get(&selector.rpc())) + .copied() + } +} + +fn validate_service_config(config: &GatewayInterceptorConfig) -> Result<()> { + if config.name.trim().is_empty() { + return Err(InterceptorError::Config( + "interceptor name must not be empty".to_string(), + )); + } + if config.grpc_endpoint.trim().is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{}' grpc_endpoint must not be empty", + config.name + ))); + } + if let Some(timeout) = config.timeout.as_deref() { + parse_duration(timeout)?; + } + Ok(()) +} + +fn normalize_binding( + interceptor_name: &str, + binding: &InterceptorBinding, + service_default: FailurePolicy, + overrides: &OverrideIndex<'_>, +) -> Result> { + let binding_id = binding.id.trim(); + if binding_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared a binding without id" + ))); + } + + let selector = selector_from_proto(binding.selector.as_ref())?; + let mut phases = binding + .phases + .iter() + .map(|phase| { + GatewayInterceptorPhase::try_from(*phase) + .map_err(|_| InterceptorError::Config("unknown binding phase".to_string())) + .and_then(Phase::try_from) + }) + .collect::>>()?; + phases.sort_unstable(); + phases.dedup(); + if phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' declares no phases" + ))); + } + + let mut failure_policy = + parse_failure_policy(binding.failure_policy.as_str()).unwrap_or(service_default); + + if let Some(override_cfg) = overrides.get(binding_id, &selector) { + if let Some(override_selector) = override_selector(override_cfg)? + && override_selector != selector + { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot widen selector '{}' to '{}'", + selector.rpc(), + override_selector.rpc() + ))); + } + if override_cfg.disabled { + return Ok(None); + } + if let Some(override_phases) = &override_cfg.phases { + let override_set: BTreeSet = + override_phases.iter().copied().map(Phase::from).collect(); + let declared: BTreeSet = phases.iter().copied().collect(); + if !override_set.is_subset(&declared) { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot add phases not declared by the manifest" + ))); + } + phases = override_set.into_iter().collect(); + } + if let Some(policy) = override_cfg.failure_policy { + failure_policy = policy.into(); + } + } + + Ok(Some(NormalizedBinding { + binding_id: binding_id.to_string(), + selector, + phases, + failure_policy, + })) +} + +fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { + let selector = selector + .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; + if !selector.rpc.trim().is_empty() { + return parse_rpc_selector(&selector.rpc); + } + if selector.service.trim().is_empty() || selector.method.trim().is_empty() { + return Err(InterceptorError::Config( + "binding selector requires rpc or service+method".to_string(), + )); + } + Ok(RpcSelector::new( + selector.service.trim(), + selector.method.trim(), + )) +} + +fn override_selector( + override_cfg: &GatewayInterceptorBindingOverride, +) -> Result> { + if let Some(rpc) = override_cfg.rpc.as_deref() + && !rpc.trim().is_empty() + { + return parse_rpc_selector(rpc).map(Some); + } + match ( + override_cfg + .service + .as_deref() + .filter(|v| !v.trim().is_empty()), + override_cfg + .method + .as_deref() + .filter(|v| !v.trim().is_empty()), + ) { + (Some(service), Some(method)) => Ok(Some(RpcSelector::new(service.trim(), method.trim()))), + (None, None) => Ok(None), + _ => Err(InterceptorError::Config( + "binding override selector requires both service and method".to_string(), + )), + } +} + +fn parse_rpc_selector(value: &str) -> Result { + let (service, method) = value.trim().split_once('/').ok_or_else(|| { + InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + )) + })?; + if service.is_empty() || method.is_empty() || method.contains('/') { + return Err(InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + ))); + } + Ok(RpcSelector::new(service, method)) +} + +fn parse_failure_policy(value: &str) -> Result { + match value.trim() { + "" => Err(InterceptorError::Config( + "failure_policy must not be empty".to_string(), + )), + "fail_closed" => Ok(FailurePolicy::FailClosed), + "fail_open" => Ok(FailurePolicy::FailOpen), + "ignore" => Ok(FailurePolicy::Ignore), + other => Err(InterceptorError::Config(format!( + "unsupported failure_policy '{other}'" + ))), + } +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(InterceptorError::Config( + "timeout must not be empty".to_string(), + )); + } + if let Some(ms) = value.strip_suffix("ms") { + let millis = ms + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_millis(millis)); + } + if let Some(seconds) = value.strip_suffix('s') { + let seconds = seconds + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_secs(seconds)); + } + Err(InterceptorError::Config(format!( + "invalid timeout '{value}'; expected suffix ms or s" + ))) +} + +async fn connect_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if let Some(path) = endpoint.strip_prefix("unix://") { + return connect_unix_endpoint(PathBuf::from(path)).await; + } + Endpoint::from_shared(endpoint.to_string()) + .map_err(|e| { + InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) + })? + .connect() + .await + .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) +} + +#[cfg(unix)] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + let display = path.display().to_string(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) +} + +#[cfg(not(unix))] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + Err(InterceptorError::Config(format!( + "unix interceptor endpoints are not supported on this platform: {}", + path.display() + ))) +} + +async fn evaluate_plan( + plan: &BindingPlan, + operation: Value, + context: &EvaluationContext, +) -> Result { + let operation = json_to_struct(operation)?; + let current_state = context + .current_state + .clone() + .map(json_to_struct) + .transpose()? + .unwrap_or_default(); + let request = InterceptorEvaluation { + interceptor_name: plan.interceptor_name.clone(), + binding_id: plan.binding_id.clone(), + service: plan.selector.service.clone(), + method: plan.selector.method.clone(), + phase: plan.phase.to_proto() as i32, + operation: Some(operation), + current_state: Some(current_state), + principal: context.principal.clone().into_iter().collect(), + }; + + let start = Instant::now(); + let result = tokio::time::timeout( + plan.timeout, + plan.client.clone().evaluate(Request::new(request)), + ) + .await + .map_err(|_| InterceptorError::Transport("evaluation timed out".to_string()))? + .map_err(|status| InterceptorError::Transport(status.to_string()))? + .into_inner(); + let encoded_len = result.encoded_len(); + histogram!("openshell_gateway_interceptor_latency_seconds") + .record(start.elapsed().as_secs_f64()); + if encoded_len > plan.max_response_bytes { + return Err(InterceptorError::InvalidResult(format!( + "interceptor response exceeded max_response_bytes ({} > {})", + encoded_len, plan.max_response_bytes + ))); + } + Ok(result) +} + +fn apply_failure_policy( + plan: &BindingPlan, + err: &InterceptorError, +) -> std::result::Result<(), Status> { + match plan.failure_policy { + FailurePolicy::FailClosed => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed closed" + ); + counter!("openshell_gateway_interceptor_fail_closed_total").increment(1); + Err(Status::permission_denied(format!( + "gateway interceptor '{}' failed closed: {err}", + plan.interceptor_name + ))) + } + FailurePolicy::FailOpen => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed open" + ); + counter!("openshell_gateway_interceptor_fail_open_total").increment(1); + Ok(()) + } + FailurePolicy::Ignore => { + debug!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failure ignored" + ); + Ok(()) + } + } +} + +fn validate_result_contract(plan: &BindingPlan, result: &InterceptorResult) -> Result<()> { + if result.patches.len() > plan.max_patches { + return Err(InterceptorError::InvalidResult(format!( + "interceptor returned too many patches ({} > {})", + result.patches.len(), + plan.max_patches + ))); + } + if plan.phase != Phase::ModifyOperation && !result.patches.is_empty() { + return Err(InterceptorError::InvalidResult(format!( + "patches are invalid during {}", + plan.phase.as_str() + ))); + } + if plan.phase == Phase::PostCommit && (!result.allowed || !result.patches.is_empty()) { + return Err(InterceptorError::InvalidResult( + "post_commit cannot deny or mutate operations".to_string(), + )); + } + Ok(()) +} + +fn status_from_result(result: &InterceptorResult, reason: String) -> Status { + let code = grpc_code_from_name(&result.status_code).unwrap_or(Code::PermissionDenied); + Status::new(code, reason) +} + +fn grpc_code_from_name(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "OK" => Some(Code::Ok), + "CANCELLED" => Some(Code::Cancelled), + "UNKNOWN" => Some(Code::Unknown), + "INVALID_ARGUMENT" => Some(Code::InvalidArgument), + "DEADLINE_EXCEEDED" => Some(Code::DeadlineExceeded), + "NOT_FOUND" => Some(Code::NotFound), + "ALREADY_EXISTS" => Some(Code::AlreadyExists), + "PERMISSION_DENIED" => Some(Code::PermissionDenied), + "RESOURCE_EXHAUSTED" => Some(Code::ResourceExhausted), + "FAILED_PRECONDITION" => Some(Code::FailedPrecondition), + "ABORTED" => Some(Code::Aborted), + "OUT_OF_RANGE" => Some(Code::OutOfRange), + "UNIMPLEMENTED" => Some(Code::Unimplemented), + "INTERNAL" => Some(Code::Internal), + "UNAVAILABLE" => Some(Code::Unavailable), + "DATA_LOSS" => Some(Code::DataLoss), + "UNAUTHENTICATED" => Some(Code::Unauthenticated), + _ => None, + } +} + +fn json_patch_operations(patches: &[JsonPatch]) -> Result> { + let mut raw = Vec::with_capacity(patches.len()); + for patch in patches { + let mut op = Map::new(); + op.insert("op".to_string(), Value::String(patch.op.clone())); + op.insert("path".to_string(), Value::String(patch.path.clone())); + if !patch.from.is_empty() { + op.insert("from".to_string(), Value::String(patch.from.clone())); + } + if let Some(value) = patch.value.as_ref() { + op.insert("value".to_string(), protobuf_value_to_json(value)); + } + raw.push(Value::Object(op)); + } + serde_json::from_value(Value::Array(raw)) + .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) +} + +fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { + counter!("openshell_gateway_interceptor_evaluations_total").increment(1); + if patch_count > 0 { + counter!("openshell_gateway_interceptor_patches_total").increment(patch_count as u64); + } +} + +#[derive(Debug, Clone)] +struct GrpcFrame { + compressed: bool, + message: Vec, +} + +impl GrpcFrame { + fn decode(body: &[u8]) -> std::result::Result { + if body.len() < GRPC_HEADER_LEN { + return Err(Status::invalid_argument("gRPC request frame is too short")); + } + let compressed = body[0] != 0; + if compressed { + return Err(Status::unimplemented( + "gateway interceptors do not support compressed gRPC requests", + )); + } + let len = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize; + if body.len() != GRPC_HEADER_LEN + len { + return Err(Status::invalid_argument( + "gRPC request must contain exactly one frame", + )); + } + Ok(Self { + compressed, + message: body[GRPC_HEADER_LEN..].to_vec(), + }) + } + + fn encode(&self) -> Result> { + if self.compressed { + return Err(InterceptorError::Transcode( + "compressed gRPC frames are not supported".to_string(), + )); + } + let len = u32::try_from(self.message.len()) + .map_err(|_| InterceptorError::Transcode("message exceeds u32".to_string()))?; + let mut out = Vec::with_capacity(GRPC_HEADER_LEN + self.message.len()); + out.push(0); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&self.message); + Ok(out) + } +} + +#[derive(Debug, Clone, Default)] +struct ProtoDescriptors { + messages: HashMap, + enums: HashMap, +} + +impl ProtoDescriptors { + fn from_descriptor_set(bytes: &[u8]) -> Result { + let set = FileDescriptorSet::decode(bytes) + .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + let mut descriptors = Self::default(); + for file in &set.file { + descriptors.add_file(file)?; + } + Ok(descriptors) + } + + fn add_file(&mut self, file: &FileDescriptorProto) -> Result<()> { + let package = file.package.as_deref().unwrap_or(""); + for message in &file.message_type { + self.add_message(package, None, message)?; + } + for enum_desc in &file.enum_type { + self.add_enum(package, None, enum_desc); + } + Ok(()) + } + + fn add_message( + &mut self, + package: &str, + parent: Option<&str>, + message: &DescriptorProto, + ) -> Result<()> { + let name = message.name.as_deref().unwrap_or(""); + let full_name = join_type_name(package, parent, name); + let map_entry = message + .options + .as_ref() + .is_some_and(prost_types::MessageOptions::map_entry); + let mut fields = BTreeMap::new(); + let mut fields_by_json = HashMap::new(); + for field in &message.field { + let field_desc = FieldDesc::from_proto(field)?; + fields_by_json.insert(field_desc.json_name.clone(), field_desc.number); + fields_by_json.insert(field_desc.name.clone(), field_desc.number); + fields.insert(field_desc.number, field_desc); + } + self.messages.insert( + full_name.clone(), + MessageDesc { + fields, + fields_by_json, + map_entry, + }, + ); + for nested in &message.nested_type { + self.add_message(package, Some(&full_name), nested)?; + } + for enum_desc in &message.enum_type { + self.add_enum(package, Some(&full_name), enum_desc); + } + Ok(()) + } + + fn add_enum(&mut self, package: &str, parent: Option<&str>, enum_desc: &EnumDescriptorProto) { + let name = enum_desc.name.as_deref().unwrap_or(""); + let full_name = join_type_name(package, parent, name); + let mut names_by_number = HashMap::new(); + let mut numbers_by_name = HashMap::new(); + for value in &enum_desc.value { + let Some(name) = value.name.as_ref() else { + continue; + }; + let number = value.number(); + names_by_number.insert(number, name.clone()); + numbers_by_name.insert(name.clone(), number); + } + self.enums.insert( + full_name, + EnumDesc { + names_by_number, + numbers_by_name, + }, + ); + } + + fn message(&self, name: &str) -> Result<&MessageDesc> { + self.messages + .get(trim_type_name(name)) + .ok_or_else(|| InterceptorError::Transcode(format!("unknown message type '{name}'"))) + } + + fn field_is_map(&self, field: &FieldDesc) -> bool { + field.repeated + && field.kind == FieldKind::Message + && field + .type_name + .as_ref() + .and_then(|name| self.messages.get(name)) + .is_some_and(|message| message.map_entry) + } + + fn decode_message_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + let message = self.message(type_name)?; + let mut values: HashMap> = HashMap::new(); + let mut input = bytes; + while !input.is_empty() { + let key = decode_varint(&mut input)?; + let field_number = u32::try_from(key >> 3) + .map_err(|_| InterceptorError::Transcode("field number overflow".to_string()))?; + let wire_type = u8::try_from(key & 0x07) + .map_err(|_| InterceptorError::Transcode("wire type overflow".to_string()))?; + let Some(field) = message.fields.get(&field_number) else { + skip_unknown(wire_type, &mut input)?; + continue; + }; + let decoded = self.decode_field_value(field, wire_type, &mut input)?; + values.entry(field_number).or_default().extend(decoded); + } + + let mut out = Map::new(); + for field in message.fields.values() { + let field_values = values.remove(&field.number).unwrap_or_default(); + if field_values.is_empty() && !field.repeated { + continue; + } + let value = if self.field_is_map(field) { + Self::map_values_to_json(field, field_values)? + } else if field.repeated { + Value::Array(field_values) + } else { + field_values.last().cloned().expect("empty values skipped") + }; + out.insert(field.json_name.clone(), value); + } + Ok(Value::Object(out)) + } + + fn decode_field_value( + &self, + field: &FieldDesc, + wire_type: u8, + input: &mut &[u8], + ) -> Result> { + if wire_type == 2 && field.repeated && field.is_packable() { + let bytes = decode_length_delimited(input)?; + let mut packed = bytes.as_slice(); + let mut values = Vec::new(); + while !packed.is_empty() { + values.push(self.decode_scalar_json( + field, + field.packed_wire_type(), + &mut packed, + )?); + } + return Ok(values); + } + Ok(vec![self.decode_scalar_json(field, wire_type, input)?]) + } + + fn decode_scalar_json( + &self, + field: &FieldDesc, + wire_type: u8, + input: &mut &[u8], + ) -> Result { + match field.kind { + FieldKind::Double => { + expect_wire(wire_type, 1)?; + Ok(number_json(f64::from_bits(decode_fixed64(input)?))) + } + FieldKind::Float => { + expect_wire(wire_type, 5)?; + Ok(number_json(f64::from(f32::from_bits(decode_fixed32( + input, + )?)))) + } + FieldKind::Int64 | FieldKind::Sfixed64 | FieldKind::Sint64 => { + let value = if field.kind == FieldKind::Sfixed64 { + expect_wire(wire_type, 1)?; + decode_fixed64(input)?.cast_signed() + } else if field.kind == FieldKind::Sint64 { + expect_wire(wire_type, 0)?; + decode_zigzag64(decode_varint(input)?) + } else { + expect_wire(wire_type, 0)?; + decode_varint(input)?.cast_signed() + }; + Ok(Value::String(value.to_string())) + } + FieldKind::Uint64 | FieldKind::Fixed64 => { + let value = if field.kind == FieldKind::Fixed64 { + expect_wire(wire_type, 1)?; + decode_fixed64(input)? + } else { + expect_wire(wire_type, 0)?; + decode_varint(input)? + }; + Ok(Value::String(value.to_string())) + } + FieldKind::Int32 | FieldKind::Sint32 | FieldKind::Sfixed32 => { + let value = if field.kind == FieldKind::Sfixed32 { + expect_wire(wire_type, 5)?; + decode_fixed32(input)?.cast_signed() + } else if field.kind == FieldKind::Sint32 { + expect_wire(wire_type, 0)?; + let raw = u32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds sint32", field.name)) + })?; + decode_zigzag32(raw) + } else { + expect_wire(wire_type, 0)?; + i32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds int32", field.name)) + })? + }; + Ok(Value::Number(Number::from(value))) + } + FieldKind::Uint32 | FieldKind::Fixed32 => { + let value = if field.kind == FieldKind::Fixed32 { + expect_wire(wire_type, 5)?; + decode_fixed32(input)? + } else { + expect_wire(wire_type, 0)?; + u32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds u32", field.name)) + })? + }; + Ok(Value::Number(Number::from(value))) + } + FieldKind::Bool => { + expect_wire(wire_type, 0)?; + Ok(Value::Bool(decode_varint(input)? != 0)) + } + FieldKind::String => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + String::from_utf8(bytes) + .map(Value::String) + .map_err(|e| InterceptorError::Transcode(format!("invalid UTF-8: {e}"))) + } + FieldKind::Bytes => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + Ok(Value::String( + base64::engine::general_purpose::STANDARD.encode(bytes), + )) + } + FieldKind::Enum => { + expect_wire(wire_type, 0)?; + let number = i32::try_from(decode_varint(input)?).map_err(|_| { + InterceptorError::Transcode(format!("{} exceeds enum int32", field.name)) + })?; + if let Some(enum_type) = field + .type_name + .as_ref() + .and_then(|name| self.enums.get(name)) + && let Some(name) = enum_type.names_by_number.get(&number) + { + return Ok(Value::String(name.clone())); + } + Ok(Value::Number(Number::from(number))) + } + FieldKind::Message => { + expect_wire(wire_type, 2)?; + let bytes = decode_length_delimited(input)?; + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "message field {} lacks type_name", + field.name + )) + })?; + self.decode_message_to_json(type_name, &bytes) + } + } + } + + fn map_values_to_json(_field: &FieldDesc, values: Vec) -> Result { + let mut map = Map::new(); + for value in values { + let Value::Object(mut entry) = value else { + return Err(InterceptorError::Transcode( + "map entry was not object".to_string(), + )); + }; + let key = entry + .remove("key") + .ok_or_else(|| InterceptorError::Transcode("map entry missing key".to_string()))?; + let key = match key { + Value::String(value) => value, + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + other => { + return Err(InterceptorError::Transcode(format!( + "unsupported map key value {other:?}" + ))); + } + }; + let value = entry.remove("value").unwrap_or(Value::Null); + map.insert(key, value); + } + Ok(Value::Object(map)) + } + + fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + let message = self.message(type_name)?; + let Value::Object(map) = value else { + return Err(InterceptorError::Transcode(format!( + "{type_name} JSON must be an object" + ))); + }; + let mut out = Vec::new(); + for (json_name, value) in map { + if value.is_null() { + continue; + } + let Some(number) = message.fields_by_json.get(json_name) else { + return Err(InterceptorError::Transcode(format!( + "unknown field '{json_name}' on {type_name}" + ))); + }; + let field = message.fields.get(number).expect("field index is valid"); + if self.field_is_map(field) { + self.encode_map_field(field, value, &mut out)?; + } else if field.repeated { + let Value::Array(values) = value else { + return Err(InterceptorError::Transcode(format!( + "repeated field '{}' must be an array", + field.json_name + ))); + }; + for item in values { + self.encode_field(field, item, &mut out)?; + } + } else { + self.encode_field(field, value, &mut out)?; + } + } + Ok(out) + } + + fn encode_map_field(&self, field: &FieldDesc, value: &Value, out: &mut Vec) -> Result<()> { + let Value::Object(map) = value else { + return Err(InterceptorError::Transcode(format!( + "map field '{}' must be an object", + field.json_name + ))); + }; + let entry_type = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!("map field '{}' lacks entry type", field.name)) + })?; + for (key, value) in map { + let entry = Value::Object(Map::from_iter([ + ("key".to_string(), Value::String(key.clone())), + ("value".to_string(), value.clone()), + ])); + let encoded = self.encode_json_to_message(entry_type, &entry)?; + encode_key(field.number, 2, out); + encode_length_delimited(&encoded, out)?; + } + Ok(()) + } + + fn encode_field(&self, field: &FieldDesc, value: &Value, out: &mut Vec) -> Result<()> { + match field.kind { + FieldKind::Double => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_f64(value, &field.json_name)?.to_bits().to_le_bytes()); + } + FieldKind::Float => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_f32(value, &field.json_name)?.to_bits().to_le_bytes()); + } + FieldKind::Int64 => { + encode_key(field.number, 0, out); + encode_varint(json_i64(value, &field.json_name)?.cast_unsigned(), out); + } + FieldKind::Uint64 => { + encode_key(field.number, 0, out); + encode_varint(json_u64(value, &field.json_name)?, out); + } + FieldKind::Int32 => { + encode_key(field.number, 0, out); + encode_varint( + u64::from(json_i32(value, &field.json_name)?.cast_unsigned()), + out, + ); + } + FieldKind::Fixed64 => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_u64(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Fixed32 => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_u32(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Bool => { + encode_key(field.number, 0, out); + encode_varint(u64::from(json_bool(value, &field.json_name)?), out); + } + FieldKind::String => { + encode_key(field.number, 2, out); + let value = json_string(value, &field.json_name)?; + encode_length_delimited(value.as_bytes(), out)?; + } + FieldKind::Bytes => { + encode_key(field.number, 2, out); + let decoded = base64::engine::general_purpose::STANDARD + .decode(json_string(value, &field.json_name)?) + .map_err(|e| { + InterceptorError::Transcode(format!("invalid base64 bytes: {e}")) + })?; + encode_length_delimited(&decoded, out)?; + } + FieldKind::Uint32 => { + encode_key(field.number, 0, out); + encode_varint(u64::from(json_u32(value, &field.json_name)?), out); + } + FieldKind::Enum => { + encode_key(field.number, 0, out); + let number = self.json_enum_number(field, value)?; + encode_varint(u64::from(number.cast_unsigned()), out); + } + FieldKind::Sfixed32 => { + encode_key(field.number, 5, out); + out.extend_from_slice(&json_i32(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Sfixed64 => { + encode_key(field.number, 1, out); + out.extend_from_slice(&json_i64(value, &field.json_name)?.to_le_bytes()); + } + FieldKind::Sint32 => { + encode_key(field.number, 0, out); + encode_varint( + u64::from(encode_zigzag32(json_i32(value, &field.json_name)?)), + out, + ); + } + FieldKind::Sint64 => { + encode_key(field.number, 0, out); + encode_varint(encode_zigzag64(json_i64(value, &field.json_name)?), out); + } + FieldKind::Message => { + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "message field {} lacks type_name", + field.name + )) + })?; + let encoded = self.encode_json_to_message(type_name, value)?; + encode_key(field.number, 2, out); + encode_length_delimited(&encoded, out)?; + } + } + Ok(()) + } + + fn json_enum_number(&self, field: &FieldDesc, value: &Value) -> Result { + match value { + Value::String(name) => { + let type_name = field.type_name.as_deref().ok_or_else(|| { + InterceptorError::Transcode(format!( + "enum field {} lacks type_name", + field.name + )) + })?; + self.enums + .get(type_name) + .and_then(|desc| desc.numbers_by_name.get(name)) + .copied() + .ok_or_else(|| { + InterceptorError::Transcode(format!( + "unknown enum value '{name}' for {}", + field.json_name + )) + }) + } + Value::Number(number) => number + .as_i64() + .and_then(|value| i32::try_from(value).ok()) + .ok_or_else(|| { + InterceptorError::Transcode(format!("{} must be enum", field.json_name)) + }), + _ => Err(InterceptorError::Transcode(format!( + "{} must be enum string or number", + field.json_name + ))), + } + } +} + +#[derive(Debug, Clone, Default)] +struct MessageDesc { + fields: BTreeMap, + fields_by_json: HashMap, + map_entry: bool, +} + +#[derive(Debug, Clone)] +struct FieldDesc { + name: String, + json_name: String, + number: u32, + repeated: bool, + kind: FieldKind, + type_name: Option, +} + +impl FieldDesc { + fn from_proto(field: &FieldDescriptorProto) -> Result { + let name = field.name.clone().unwrap_or_default(); + let json_name = field + .json_name + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| snake_to_lower_camel(&name)); + let number = u32::try_from(field.number()) + .map_err(|_| InterceptorError::Config(format!("field '{name}' has invalid number")))?; + let repeated = field.label() == Label::Repeated; + let kind = FieldKind::from_type(field.r#type())?; + let type_name = field + .type_name + .as_ref() + .map(|name| trim_type_name(name).to_string()); + Ok(Self { + name, + json_name, + number, + repeated, + kind, + type_name, + }) + } + + fn is_packable(&self) -> bool { + !matches!( + self.kind, + FieldKind::String | FieldKind::Bytes | FieldKind::Message + ) + } + + fn packed_wire_type(&self) -> u8 { + match self.kind { + FieldKind::Double | FieldKind::Fixed64 | FieldKind::Sfixed64 => 1, + FieldKind::Float | FieldKind::Fixed32 | FieldKind::Sfixed32 => 5, + _ => 0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldKind { + Double, + Float, + Int64, + Uint64, + Int32, + Fixed64, + Fixed32, + Bool, + String, + Message, + Bytes, + Uint32, + Enum, + Sfixed32, + Sfixed64, + Sint32, + Sint64, +} + +impl FieldKind { + fn from_type(value: Type) -> Result { + match value { + Type::Double => Ok(Self::Double), + Type::Float => Ok(Self::Float), + Type::Int64 => Ok(Self::Int64), + Type::Uint64 => Ok(Self::Uint64), + Type::Int32 => Ok(Self::Int32), + Type::Fixed64 => Ok(Self::Fixed64), + Type::Fixed32 => Ok(Self::Fixed32), + Type::Bool => Ok(Self::Bool), + Type::String => Ok(Self::String), + Type::Group => Err(InterceptorError::Transcode( + "protobuf groups are not supported".to_string(), + )), + Type::Message => Ok(Self::Message), + Type::Bytes => Ok(Self::Bytes), + Type::Uint32 => Ok(Self::Uint32), + Type::Enum => Ok(Self::Enum), + Type::Sfixed32 => Ok(Self::Sfixed32), + Type::Sfixed64 => Ok(Self::Sfixed64), + Type::Sint32 => Ok(Self::Sint32), + Type::Sint64 => Ok(Self::Sint64), + } + } +} + +#[derive(Debug, Clone, Default)] +struct EnumDesc { + names_by_number: HashMap, + numbers_by_name: HashMap, +} + +fn join_type_name(package: &str, parent: Option<&str>, name: &str) -> String { + parent.map_or_else( + || { + if package.is_empty() { + name.to_string() + } else { + format!("{package}.{name}") + } + }, + |parent| format!("{parent}.{name}"), + ) +} + +fn trim_type_name(name: &str) -> &str { + name.strip_prefix('.').unwrap_or(name) +} + +fn snake_to_lower_camel(value: &str) -> String { + let mut out = String::new(); + let mut uppercase = false; + for ch in value.chars() { + if ch == '_' { + uppercase = true; + } else if uppercase { + out.extend(ch.to_uppercase()); + uppercase = false; + } else { + out.push(ch); + } + } + out +} + +fn json_to_struct(value: Value) -> Result { + match value { + Value::Object(fields) => Ok(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "operation JSON must be an object".to_string(), + )), + } +} + +fn json_to_protobuf_value(value: Value) -> Result { + let kind = match value { + Value::Null => prost_types::value::Kind::NullValue(0), + Value::Bool(value) => prost_types::value::Kind::BoolValue(value), + Value::Number(value) => prost_types::value::Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode("invalid JSON number".to_string()))?, + ), + Value::String(value) => prost_types::value::Kind::StringValue(value), + Value::Array(values) => prost_types::value::Kind::ListValue(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + Value::Object(fields) => prost_types::value::Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + }; + Ok(prost_types::Value { kind: Some(kind) }) +} + +fn protobuf_value_to_json(value: &prost_types::Value) -> Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, + Some(prost_types::value::Kind::NumberValue(value)) => number_json(*value), + Some(prost_types::value::Kind::StringValue(value)) => Value::String(value.clone()), + Some(prost_types::value::Kind::BoolValue(value)) => Value::Bool(*value), + Some(prost_types::value::Kind::StructValue(value)) => Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) + .collect(), + ), + Some(prost_types::value::Kind::ListValue(value)) => { + Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) + } + } +} + +fn number_json(value: f64) -> Value { + Number::from_f64(value).map_or(Value::Null, Value::Number) +} + +fn expect_wire(actual: u8, expected: u8) -> Result<()> { + if actual == expected { + Ok(()) + } else { + Err(InterceptorError::Transcode(format!( + "wire type mismatch: got {actual}, expected {expected}" + ))) + } +} + +fn decode_varint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let Some((&byte, rest)) = input.split_first() else { + return Err(InterceptorError::Transcode("truncated varint".to_string())); + }; + *input = rest; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err(InterceptorError::Transcode("varint overflow".to_string())) +} + +fn decode_fixed32(input: &mut &[u8]) -> Result { + if input.len() < 4 { + return Err(InterceptorError::Transcode("truncated fixed32".to_string())); + } + let (bytes, rest) = input.split_at(4); + *input = rest; + Ok(u32::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_fixed64(input: &mut &[u8]) -> Result { + if input.len() < 8 { + return Err(InterceptorError::Transcode("truncated fixed64".to_string())); + } + let (bytes, rest) = input.split_at(8); + *input = rest; + Ok(u64::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_length_delimited(input: &mut &[u8]) -> Result> { + let len = usize::try_from(decode_varint(input)?) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?; + if input.len() < len { + return Err(InterceptorError::Transcode( + "truncated length-delimited field".to_string(), + )); + } + let (bytes, rest) = input.split_at(len); + *input = rest; + Ok(bytes.to_vec()) +} + +fn skip_unknown(wire_type: u8, input: &mut &[u8]) -> Result<()> { + match wire_type { + 0 => { + decode_varint(input)?; + } + 1 => { + decode_fixed64(input)?; + } + 2 => { + decode_length_delimited(input)?; + } + 5 => { + decode_fixed32(input)?; + } + other => { + return Err(InterceptorError::Transcode(format!( + "unsupported unknown wire type {other}" + ))); + } + } + Ok(()) +} + +fn decode_zigzag32(value: u32) -> i32 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn decode_zigzag64(value: u64) -> i64 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn encode_zigzag32(value: i32) -> u32 { + ((value << 1) ^ (value >> 31)).cast_unsigned() +} + +fn encode_zigzag64(value: i64) -> u64 { + ((value << 1) ^ (value >> 63)).cast_unsigned() +} + +fn encode_key(field_number: u32, wire_type: u8, out: &mut Vec) { + encode_varint((u64::from(field_number) << 3) | u64::from(wire_type), out); +} + +fn encode_varint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + let byte = u8::try_from(value & 0x7f).expect("masked varint byte fits u8"); + out.push(byte | 0x80); + value >>= 7; + } + out.push(u8::try_from(value).expect("final varint byte fits u8")); +} + +fn encode_length_delimited(bytes: &[u8], out: &mut Vec) -> Result<()> { + encode_varint( + u64::try_from(bytes.len()) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?, + out, + ); + out.extend_from_slice(bytes); + Ok(()) +} + +fn json_string<'a>(value: &'a Value, field: &str) -> Result<&'a str> { + value + .as_str() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a string"))) +} + +fn json_bool(value: &Value, field: &str) -> Result { + value + .as_bool() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a bool"))) +} + +fn json_f64(value: &Value, field: &str) -> Result { + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a number"))) +} + +#[allow(clippy::cast_possible_truncation)] +fn json_f32(value: &Value, field: &str) -> Result { + let value = json_f64(value, field)?; + if value.is_finite() && value >= f64::from(f32::MIN) && value <= f64::from(f32::MAX) { + Ok(value as f32) + } else { + Err(InterceptorError::Transcode(format!( + "{field} must be a finite float" + ))) + } +} + +fn json_i64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be int64 string"))), + Value::Number(value) => value + .as_i64() + .or_else(|| integral_f64(value).and_then(|value| i64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be int64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be int64" + ))), + } +} + +fn json_u64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be uint64 string"))), + Value::Number(value) => value + .as_u64() + .or_else(|| integral_f64(value).and_then(|value| u64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be uint64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be uint64" + ))), + } +} + +fn integral_f64(value: &Number) -> Option { + let value = value.as_f64()?; + if value.fract() == 0.0 && value.is_finite() { + format!("{value:.0}").parse().ok() + } else { + None + } +} + +fn json_i32(value: &Value, field: &str) -> Result { + i32::try_from(json_i64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds int32"))) +} + +fn json_u32(value: &Value, field: &str) -> Result { + u32::try_from(json_u64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds uint32"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{CreateSandboxRequest, SandboxSpec}; + + #[test] + fn parses_timeout_suffixes() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert!(parse_duration("2").is_err()); + } + + #[test] + fn dynamic_create_sandbox_round_trip_uses_json_names() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::from([("team".to_string(), "agent".to_string())]), + }; + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + assert_eq!(json["spec"]["providers"][0], "github"); + assert_eq!(json["labels"]["team"], "agent"); + let encoded = descriptors + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } +} diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs new file mode 100644 index 000000000..6ddaa08a9 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptable `OpenShell` route classification. + +use std::collections::{BTreeMap, BTreeSet}; + +use prost::Message as _; +use prost_types::FileDescriptorSet; + +use crate::{InterceptorError, Result}; + +const SERVICE_OPEN_SHELL: &str = "openshell.v1.OpenShell"; + +/// Unary `openshell.v1.OpenShell` methods that are deliberately excluded from +/// gateway interception. New unary methods are interceptable by default unless +/// added here in the same change. +pub const NON_INTERCEPTABLE_METHODS: &[&str] = &[ + "Health", + "WatchSandbox", + "ExecSandbox", + "ForwardTcp", + "ExecSandboxInteractive", + "PushSandboxLogs", + "ConnectSupervisor", + "RelayStream", + "GetSandboxConfig", + "GetSandboxProviderEnvironment", + "ReportPolicyStatus", + "SubmitPolicyAnalysis", + "IssueSandboxToken", + "RefreshSandboxToken", + "GetSandbox", + "ListSandboxes", + "ListSandboxProviders", + "GetProvider", + "ListProviders", + "ListProviderProfiles", + "GetProviderProfile", + "LintProviderProfiles", + "GetProviderRefreshStatus", + "GetGatewayConfig", + "GetSandboxPolicyStatus", + "ListSandboxPolicies", + "GetSandboxLogs", + "GetDraftPolicy", + "GetDraftHistory", + "GetService", + "ListServices", +]; + +#[derive(Debug, Clone)] +pub struct OpenShellRouteIndex { + all_methods: BTreeSet, + unary_methods: BTreeSet, + input_types: BTreeMap, +} + +impl OpenShellRouteIndex { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + let set = FileDescriptorSet::decode(bytes) + .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + let mut all_methods = BTreeSet::new(); + let mut unary_methods = BTreeSet::new(); + let mut input_types = BTreeMap::new(); + + for file in &set.file { + if file.package.as_deref() != Some("openshell.v1") { + continue; + } + for service in &file.service { + if service.name.as_deref() != Some("OpenShell") { + continue; + } + for method in &service.method { + let name = method.name.clone().unwrap_or_default(); + all_methods.insert(name.clone()); + if !method.client_streaming.unwrap_or(false) + && !method.server_streaming.unwrap_or(false) + { + let input_type = method + .input_type + .as_deref() + .unwrap_or_default() + .strip_prefix('.') + .unwrap_or_else(|| method.input_type.as_deref().unwrap_or_default()) + .to_string(); + unary_methods.insert(name.clone()); + input_types.insert(name, input_type); + } + } + } + } + + let index = Self { + all_methods, + unary_methods, + input_types, + }; + index.validate_non_interceptable_list()?; + Ok(index) + } + + #[must_use] + pub fn is_interceptable(&self, service: &str, method: &str) -> bool { + service == SERVICE_OPEN_SHELL + && self.unary_methods.contains(method) + && !NON_INTERCEPTABLE_METHODS.contains(&method) + } + + #[must_use] + pub fn input_type(&self, service: &str, method: &str) -> Option<&str> { + if service == SERVICE_OPEN_SHELL && self.unary_methods.contains(method) { + self.input_types.get(method).map(String::as_str) + } else { + None + } + } + + fn validate_non_interceptable_list(&self) -> Result<()> { + let mut stale = Vec::new(); + for method in NON_INTERCEPTABLE_METHODS { + if !self.all_methods.contains(*method) { + stale.push((*method).to_string()); + } + } + if !stale.is_empty() { + return Err(InterceptorError::Config(format!( + "non-interceptable route list has stale methods: {stale:?}" + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_interceptable_entries_match_real_methods() { + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + } + + #[test] + fn write_methods_are_interceptable_by_default() { + let index = + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + assert!(index.is_interceptable("openshell.v1.OpenShell", "CreateSandbox")); + assert!(index.is_interceptable("openshell.v1.OpenShell", "UpdateConfig")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "GetSandbox")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "WatchSandbox")); + } +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index b5c9b34d7..c4fe7e8d0 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 9aee2bc6d..0df81504a 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 13f5c647c..3967f4862 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 9e70c6472..49680ebf1 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 2aaa6e7b0..c794546c7 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -132,6 +132,24 @@ roles_claim = "realm_access.roles" admin_role = "openshell-admin" user_role = "openshell-user" scopes_claim = "" + +[[openshell.gateway.interceptors]] +name = "quota" +grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" +order = 10 +failure_policy = "fail_closed" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +id = "quota-create-sandbox" +phases = ["pre_request", "validate"] +failure_policy = "fail_closed" + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +disabled = true ``` Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to authenticate CLI callers from verified client certificates. Kubernetes deployments must leave this unset and use OIDC or a trusted access proxy; the Helm chart does not render this table. @@ -140,6 +158,10 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +`[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target unary OpenShell methods that are not on the built-in supervisor, streaming, read-only, or introspection allowlist. Request bodies are exposed as protobuf JSON objects, so adding a new unary RPC does not require handler-specific interceptor code. + +`failure_policy` accepts `fail_closed`, `fail_open`, or `ignore`. Use `ignore` only for `post_commit` bindings. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. + `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. ## Driver References diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock new file mode 100644 index 000000000..6a8a15820 --- /dev/null +++ b/examples/governance-interceptor/Cargo.lock @@ -0,0 +1,1880 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "base64", + "ipnet", + "miette", + "prost", + "prost-types", + "protobuf-src", + "serde", + "serde_json", + "thiserror", + "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +dependencies = [ + "base64", + "openshell-core", + "openshell-policy", + "prost-types", + "serde_json", + "sha2", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf-src" +version = "1.1.0+21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" +dependencies = [ + "autotools", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml new file mode 100644 index 000000000..ada1f5ecf --- /dev/null +++ b/examples/governance-interceptor/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" + +[dependencies] +base64 = "0.22" +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-policy = { path = "../../crates/openshell-policy" } +prost-types = "0.14" +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } +tonic = { version = "0.14", features = ["transport"] } + +[[bin]] +name = "governance-interceptor" +path = "src/main.rs" diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md new file mode 100644 index 000000000..c2f93a02e --- /dev/null +++ b/examples/governance-interceptor/README.md @@ -0,0 +1,64 @@ +# Governance Interceptor Example + +This standalone example implements the `openshell.gateway_interceptor.v1.GatewayInterceptor` service. It enforces a source-control governance baseline: + +- every new sandbox receives `policy.yaml` +- every new sandbox is attached to exactly `github` and `gitlab` +- every new sandbox gets an `openshell.nvidia.com/policy-signature` label +- users cannot attach or detach other providers after sandbox creation +- users cannot replace or merge sandbox policy after sandbox creation +- users cannot create provider records other than `github` and `gitlab` +- users cannot update or delete the governed `github` or `gitlab` provider records + +Run the interceptor: + +```shell +cargo run --manifest-path examples/governance-interceptor/Cargo.toml -- \ + --listen 127.0.0.1:18081 \ + --policy examples/governance-interceptor/policy.yaml +``` + +Gateway TOML snippet: + +```toml +[[openshell.gateway.interceptors]] +name = "source-control-governance" +grpc_endpoint = "http://127.0.0.1:18081" +order = 10 +failure_policy = "fail_closed" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 +``` + +Run the smoke test against a local gateway and compute driver: + +```shell +examples/governance-interceptor/smoke.sh +``` + +The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. + +Set `OPENSHELL_GOVERNANCE_SMOKE_DRIVER=docker|podman|vm|kubernetes` to force a driver. Without it, the gateway uses its existing local driver detection. + +On macOS the smoke script uses `clang`/`clang++` for native dependencies, because Apple SDK headers require Clang block syntax. It also disables `RUSTC_WRAPPER` by default so local `sccache` configuration does not affect the smoke run. + +The workspace build requires Z3. The smoke script uses `pkg-config`, `brew --prefix z3`, `/opt/homebrew/opt/z3`, or `/usr/local/opt/z3` when those locations contain `include/z3.h` and a `lib` directory. If no usable local Z3 install exists, install it first: + +```shell +brew install z3 +``` + +Build overrides: + +```shell +OPENSHELL_GOVERNANCE_CC=/path/to/clang \ +OPENSHELL_GOVERNANCE_CXX=/path/to/clang++ \ +OPENSHELL_GOVERNANCE_RUSTC_WRAPPER=sccache \ +Z3_SYS_Z3_HEADER=/path/to/include/z3.h \ +Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib \ +examples/governance-interceptor/smoke.sh +``` + +Set `OPENSHELL_GOVERNANCE_KEEP_CC=1` or `OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER=1` to preserve the caller environment. +Set `OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1` to opt into the bundled Z3 build, which downloads source metadata from GitHub and can fail in offline or rate-limited environments. diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml new file mode 100644 index 000000000..5c668a442 --- /dev/null +++ b/examples/governance-interceptor/policy.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + github: + name: github-api-readonly + endpoints: + - host: api.github.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/git } + - { path: /usr/bin/curl } + gitlab: + name: gitlab-api-readonly + endpoints: + - host: gitlab.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - { path: /usr/bin/git } + - { path: /usr/bin/curl } diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh new file mode 100755 index 000000000..52681f978 --- /dev/null +++ b/examples/governance-interceptor/smoke.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT/examples/governance-interceptor" +TMPDIR="$(mktemp -d)" +JWT_DIR="$TMPDIR/jwt" +LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" +SMOKE_LOG="$LOG_DIR/smoke.log" +INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" +GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" +HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" +DRIVER="${OPENSHELL_GOVERNANCE_SMOKE_DRIVER:-}" +SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" +ROOT_BUILD_ARGS=() +mkdir -p "$LOG_DIR" + +cleanup() { + status=$? + trap - EXIT + if [[ -n "${INTERCEPTOR_PID:-}" ]]; then kill "$INTERCEPTOR_PID" 2>/dev/null || true; fi + if [[ -n "${GATEWAY_PID:-}" ]]; then kill "$GATEWAY_PID" 2>/dev/null || true; fi + if [[ "$status" -eq 0 && "${OPENSHELL_GOVERNANCE_KEEP_LOGS:-0}" != "1" ]]; then + rm -rf "$TMPDIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + exit "$status" +} +trap cleanup EXIT + +log() { + printf '%s\n' "$*" >>"$SMOKE_LOG" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + printf ' smoke log: %s\n' "$SMOKE_LOG" >&2 + printf ' gateway log: %s\n' "$GATEWAY_LOG" >&2 + printf ' interceptor log: %s\n' "$INTERCEPTOR_LOG" >&2 + exit 1 +} + +run_step() { + local label="$1" + shift + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >>"$SMOKE_LOG" 2>&1; then + pass "$label" + else + fail "$label" + fi +} + +expect_failure() { + local label="$1" + shift + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >>"$SMOKE_LOG" 2>&1; then + fail "$label" + else + pass "$label" + fi +} + +expect_output_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -q "$needle" "$output_file"; then + pass "$label" + else + cat "$output_file" >>"$SMOKE_LOG" 2>/dev/null || true + fail "$label" + fi +} + +missing_z3() { + cat >&2 <<'EOF' +No usable local Z3 installation found. + +Install Z3 or point the build at an existing install, then rerun: + brew install z3 + Z3_SYS_Z3_HEADER=/path/to/include/z3.h Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib examples/governance-interceptor/smoke.sh + +The bundled Z3 build downloads source metadata from GitHub and can fail in offline or rate-limited environments. +To opt into that path anyway, set OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1. +EOF + exit 1 +} + +configure_native_build_env() { + if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then + export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" + export CXX="${OPENSHELL_GOVERNANCE_CXX:-clang++}" + log "Using macOS native build compiler: CC=$CC CXX=$CXX" + fi + + if [[ "${OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER:-0}" != "1" ]]; then + export RUSTC_WRAPPER="${OPENSHELL_GOVERNANCE_RUSTC_WRAPPER:-}" + fi + + if [[ -z "${RUSTC_WRAPPER:-}" ]]; then + log "Building without RUSTC_WRAPPER for reproducible smoke builds." + else + log "Using RUSTC_WRAPPER=$RUSTC_WRAPPER" + fi +} + +configure_z3_build_env() { + if [[ -n "${Z3_SYS_Z3_HEADER:-}" || -n "${Z3_LIBRARY_PATH_OVERRIDE:-}" ]]; then + log "Using caller-provided Z3 build environment." + return + fi + + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3 >/dev/null 2>&1; then + log "Using pkg-config Z3 for workspace builds." + return + fi + + z3_prefix="" + if command -v brew >/dev/null 2>&1; then + z3_prefix="$(brew --prefix z3 2>/dev/null || true)" + fi + + for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do + if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then + log "Using local Z3 from ${candidate} for workspace builds." + export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" + export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" + return + fi + done + + if [[ "${OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3:-0}" == "1" ]]; then + log "Falling back to bundled Z3 for workspace builds." + ROOT_BUILD_ARGS+=(--features bundled-z3) + return + fi + + missing_z3 +} + +generate_gateway_jwt_bundle() { + if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is required to generate local smoke-test gateway JWT keys" >&2 + exit 1 + fi + + mkdir -p "$JWT_DIR" + openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 + openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 + printf 'governance-smoke\n' > "$JWT_DIR/kid" +} + +cd "$ROOT" +configure_native_build_env +configure_z3_build_env +generate_gateway_jwt_bundle +run_step "build gateway" cargo build --quiet -p openshell-server --bin openshell-gateway "${ROOT_BUILD_ARGS[@]}" +run_step "build CLI" cargo build --quiet -p openshell-cli --bin openshell "${ROOT_BUILD_ARGS[@]}" +run_step "build governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" + +"$EXAMPLE_DIR/target/debug/governance-interceptor" \ + --listen "$INTERCEPTOR_ADDR" \ + --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & +INTERCEPTOR_PID=$! + +driver_line="" +if [[ -n "$DRIVER" ]]; then + driver_line="compute_drivers = [\"$DRIVER\"]" +fi + +cat > "$TMPDIR/gateway.toml" <"$GATEWAY_LOG" 2>&1 & +GATEWAY_PID=$! + +gateway_ready=0 +for _ in {1..60}; do + if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then + gateway_ready=1 + break + fi + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "gateway starts with interceptor" + fi + sleep 1 +done +if [[ "$gateway_ready" == "1" ]]; then + pass "gateway starts with interceptor" +else + fail "gateway starts with interceptor" +fi + +CLI=("$ROOT/target/debug/openshell" --gateway-endpoint "http://$GATEWAY_ADDR") + +run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy +run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy + +expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy + +run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true +expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" +expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + +expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + +expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + +expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + +run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" + +expect_failure "denies governed provider update" "${CLI[@]}" provider update gitlab --credential GITLAB_TOKEN=changed + +expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github + +echo "ALL PASS governance interceptor smoke" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs new file mode 100644 index 000000000..f9d2c8975 --- /dev/null +++ b/examples/governance-interceptor/src/main.rs @@ -0,0 +1,802 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, +}; +use openshell_core::proto::{ + GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, + SandboxPolicy, +}; +use openshell_policy::parse_sandbox_policy; +use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; +use serde_json::{Map, Number, Value, json}; +use sha2::{Digest, Sha256}; +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +const LABEL_KEY: &str = "openshell.nvidia.com/policy-signature"; +const SERVICE: &str = "openshell.v1.OpenShell"; +const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; + +#[derive(Clone, Debug)] +struct GovernanceInterceptorService { + policy: Value, + policy_signature: String, +} + +impl GovernanceInterceptorService { + fn from_yaml(policy_yaml: &str) -> Result { + let policy = parse_sandbox_policy(policy_yaml) + .map_err(|err| format!("failed to parse policy YAML: {err}"))?; + let policy = sandbox_policy_to_proto_json(&policy); + let policy = normalize_for_struct(policy)?; + let policy_digest: [u8; 32] = Sha256::digest( + serde_json::to_vec(&policy) + .map_err(|err| format!("failed to encode policy JSON: {err}"))?, + ) + .into(); + let policy_signature = format!("sha256-{}", URL_SAFE_NO_PAD.encode(policy_digest)); + Ok(Self { + policy, + policy_signature, + }) + } + + fn manifest() -> InterceptorManifest { + InterceptorManifest { + name: "source-control-governance".to_string(), + failure_policy: "fail_closed".to_string(), + bindings: vec![ + binding( + "govern-create-sandbox", + "CreateSandbox", + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + binding( + "govern-attach-provider", + "AttachSandboxProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-detach-provider", + "DetachSandboxProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-config", + "UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-create-provider", + "CreateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-provider", + "UpdateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-delete-provider", + "DeleteProvider", + &[GatewayInterceptorPhase::Validate], + ), + ], + } + } + + fn evaluate_inner( + &self, + evaluation: &InterceptorEvaluation, + ) -> Result { + let phase = GatewayInterceptorPhase::try_from(evaluation.phase) + .map_err(|_| Status::invalid_argument("unknown interceptor phase"))?; + let operation = evaluation + .operation + .as_ref() + .map(struct_to_json) + .unwrap_or_else(|| Value::Object(Map::new())); + + match (evaluation.method.as_str(), phase) { + ("CreateSandbox", GatewayInterceptorPhase::ModifyOperation) => { + self.patch_create_sandbox(&operation) + } + ("CreateSandbox", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_create_sandbox(&operation)) + } + ( + "AttachSandboxProvider" | "DetachSandboxProvider", + GatewayInterceptorPhase::Validate, + ) => Ok(deny( + "source-control providers are fixed at sandbox creation", + )), + ("UpdateConfig", GatewayInterceptorPhase::Validate) => { + Ok(validate_update_config(&operation)) + } + ("CreateProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_create_provider(&operation)) + } + ("UpdateProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_update_provider(&operation)) + } + ("DeleteProvider", GatewayInterceptorPhase::Validate) => { + Ok(validate_delete_provider(&operation)) + } + _ => Ok(allow()), + } + } + + fn patch_create_sandbox(&self, operation: &Value) -> Result { + let mut patches = Vec::new(); + if operation.get("spec").is_some_and(Value::is_object) { + patches.push(json_patch("add", "/spec/policy", self.policy.clone())?); + patches.push(json_patch( + "add", + "/spec/providers", + json!(GOVERNED_PROVIDERS), + )?); + } else { + patches.push(json_patch( + "add", + "/spec", + json!({ + "policy": self.policy, + "providers": GOVERNED_PROVIDERS, + }), + )?); + } + + if operation.get("labels").is_some_and(Value::is_object) { + patches.push(json_patch( + "add", + &format!("/labels/{}", json_pointer_escape(LABEL_KEY)), + Value::String(self.policy_signature.clone()), + )?); + } else { + patches.push(json_patch( + "add", + "/labels", + json!({ LABEL_KEY: self.policy_signature }), + )?); + } + + let mut result = allow(); + result.patches = patches; + result.audit_annotations.insert( + "policy_signature".to_string(), + self.policy_signature.clone(), + ); + Ok(result) + } + + fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { + if operation.pointer("/spec/policy") != Some(&self.policy) { + return deny("sandbox policy must match the source-control governance baseline"); + } + if !providers_are_governed(operation.pointer("/spec/providers")) { + return deny("sandbox providers must be exactly github and gitlab"); + } + if operation + .pointer(&format!("/labels/{}", json_pointer_escape(LABEL_KEY))) + .and_then(Value::as_str) + != Some(self.policy_signature.as_str()) + { + return deny("sandbox is missing the governance policy signature label"); + } + allow() + } +} + +#[tonic::async_trait] +impl GatewayInterceptor for GovernanceInterceptorService { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Self::manifest())) + } + + async fn evaluate( + &self, + request: Request, + ) -> Result, Status> { + self.evaluate_inner(request.get_ref()).map(Response::new) + } +} + +fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + selector: Some(InterceptorSelector { + rpc: format!("{SERVICE}/{method}"), + service: String::new(), + method: String::new(), + }), + phases: phases.iter().map(|phase| *phase as i32).collect(), + failure_policy: "fail_closed".to_string(), + } +} + +fn allow() -> InterceptorResult { + InterceptorResult { + allowed: true, + reason: String::new(), + status_code: String::new(), + patches: Vec::new(), + audit_annotations: HashMap::new(), + } +} + +fn deny(reason: &str) -> InterceptorResult { + InterceptorResult { + allowed: false, + reason: reason.to_string(), + status_code: "PERMISSION_DENIED".to_string(), + patches: Vec::new(), + audit_annotations: HashMap::new(), + } +} + +fn validate_update_config(operation: &Value) -> InterceptorResult { + let has_policy = operation + .get("policy") + .is_some_and(|value| !value.is_null()); + let has_merge_operations = operation + .get("mergeOperations") + .or_else(|| operation.get("merge_operations")) + .and_then(Value::as_array) + .is_some_and(|operations| !operations.is_empty()); + if has_policy || has_merge_operations { + deny("sandbox policy updates are blocked by the governance baseline") + } else { + allow() + } +} + +fn validate_create_provider(operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if is_governed_provider(name) { + allow() + } else { + deny("only github and gitlab provider records may be created") + } +} + +fn validate_update_provider(operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if is_governed_provider(name) { + deny("governed provider records cannot be updated") + } else { + allow() + } +} + +fn validate_delete_provider(operation: &Value) -> InterceptorResult { + let name = operation + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + if is_governed_provider(name) { + deny("governed provider records cannot be deleted") + } else { + allow() + } +} + +fn provider_name(operation: &Value) -> &str { + operation + .pointer("/provider/metadata/name") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn is_governed_provider(name: &str) -> bool { + GOVERNED_PROVIDERS.contains(&name) +} + +fn providers_are_governed(value: Option<&Value>) -> bool { + let Some(Value::Array(providers)) = value else { + return false; + }; + if providers.len() != GOVERNED_PROVIDERS.len() { + return false; + } + GOVERNED_PROVIDERS.iter().all(|provider| { + providers + .iter() + .any(|value| value.as_str() == Some(provider)) + }) +} + +fn json_patch(op: &str, path: &str, value: Value) -> Result { + Ok(JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_proto_value(&value).map_err(Status::internal)?), + from: String::new(), + }) +} + +fn json_pointer_escape(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn normalize_for_struct(value: Value) -> Result { + json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) +} + +fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Value { + let mut out = Map::new(); + out.insert("version".to_string(), json!(policy.version)); + + if let Some(filesystem) = &policy.filesystem { + out.insert( + "filesystem".to_string(), + json!({ + "includeWorkdir": filesystem.include_workdir, + "readOnly": filesystem.read_only, + "readWrite": filesystem.read_write, + }), + ); + } + + if let Some(landlock) = &policy.landlock { + out.insert( + "landlock".to_string(), + json!({ "compatibility": landlock.compatibility }), + ); + } + + if let Some(process) = &policy.process { + out.insert( + "process".to_string(), + json!({ + "runAsUser": process.run_as_user, + "runAsGroup": process.run_as_group, + }), + ); + } + + out.insert( + "networkPolicies".to_string(), + Value::Object( + policy + .network_policies + .iter() + .map(|(key, rule)| (key.clone(), network_rule_to_proto_json(rule))) + .collect(), + ), + ); + + Value::Object(out) +} + +fn network_rule_to_proto_json(rule: &NetworkPolicyRule) -> Value { + json!({ + "name": rule.name, + "endpoints": rule.endpoints.iter().map(endpoint_to_proto_json).collect::>(), + "binaries": rule.binaries.iter().map(|binary| { + json!({ "path": binary.path }) + }).collect::>(), + }) +} + +fn endpoint_to_proto_json(endpoint: &NetworkEndpoint) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "host", &endpoint.host); + insert_u32(&mut out, "port", endpoint.port); + insert_string(&mut out, "protocol", &endpoint.protocol); + insert_string(&mut out, "tls", &endpoint.tls); + insert_string(&mut out, "enforcement", &endpoint.enforcement); + insert_string(&mut out, "access", &endpoint.access); + insert_values( + &mut out, + "rules", + endpoint.rules.iter().map(l7_rule_to_proto_json).collect(), + ); + insert_strings(&mut out, "allowedIps", &endpoint.allowed_ips); + insert_values( + &mut out, + "denyRules", + endpoint + .deny_rules + .iter() + .map(l7_deny_rule_to_proto_json) + .collect(), + ); + insert_u32s(&mut out, "ports", &endpoint.ports); + insert_bool(&mut out, "allowEncodedSlash", endpoint.allow_encoded_slash); + insert_string(&mut out, "persistedQueries", &endpoint.persisted_queries); + if !endpoint.graphql_persisted_queries.is_empty() { + out.insert( + "graphqlPersistedQueries".to_string(), + Value::Object( + endpoint + .graphql_persisted_queries + .iter() + .map(|(key, operation)| { + (key.clone(), graphql_operation_to_proto_json(operation)) + }) + .collect(), + ), + ); + } + insert_u32( + &mut out, + "graphqlMaxBodyBytes", + endpoint.graphql_max_body_bytes, + ); + insert_string(&mut out, "path", &endpoint.path); + insert_bool( + &mut out, + "websocketCredentialRewrite", + endpoint.websocket_credential_rewrite, + ); + insert_bool( + &mut out, + "requestBodyCredentialRewrite", + endpoint.request_body_credential_rewrite, + ); + insert_bool(&mut out, "advisorProposed", endpoint.advisor_proposed); + Value::Object(out) +} + +fn l7_rule_to_proto_json(rule: &L7Rule) -> Value { + let mut out = Map::new(); + if let Some(allow) = &rule.allow { + out.insert("allow".to_string(), l7_allow_to_proto_json(allow)); + } + Value::Object(out) +} + +fn l7_allow_to_proto_json(allow: &L7Allow) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "method", &allow.method); + insert_string(&mut out, "path", &allow.path); + insert_string(&mut out, "command", &allow.command); + insert_query(&mut out, &allow.query); + insert_string(&mut out, "operationType", &allow.operation_type); + insert_string(&mut out, "operationName", &allow.operation_name); + insert_strings(&mut out, "fields", &allow.fields); + Value::Object(out) +} + +fn l7_deny_rule_to_proto_json(rule: &L7DenyRule) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "method", &rule.method); + insert_string(&mut out, "path", &rule.path); + insert_string(&mut out, "command", &rule.command); + insert_query(&mut out, &rule.query); + insert_string(&mut out, "operationType", &rule.operation_type); + insert_string(&mut out, "operationName", &rule.operation_name); + insert_strings(&mut out, "fields", &rule.fields); + Value::Object(out) +} + +fn graphql_operation_to_proto_json(operation: &GraphqlOperation) -> Value { + let mut out = Map::new(); + insert_string(&mut out, "operationType", &operation.operation_type); + insert_string(&mut out, "operationName", &operation.operation_name); + insert_strings(&mut out, "fields", &operation.fields); + Value::Object(out) +} + +fn insert_query( + out: &mut Map, + query: &HashMap, +) { + if query.is_empty() { + return; + } + out.insert( + "query".to_string(), + Value::Object( + query + .iter() + .map(|(key, matcher)| { + let mut value = Map::new(); + insert_string(&mut value, "glob", &matcher.glob); + insert_strings(&mut value, "any", &matcher.any); + (key.clone(), Value::Object(value)) + }) + .collect(), + ), + ); +} + +fn insert_string(out: &mut Map, key: &str, value: &str) { + if !value.is_empty() { + out.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn insert_bool(out: &mut Map, key: &str, value: bool) { + if value { + out.insert(key.to_string(), Value::Bool(value)); + } +} + +fn insert_u32(out: &mut Map, key: &str, value: u32) { + if value != 0 { + out.insert(key.to_string(), json!(value)); + } +} + +fn insert_strings(out: &mut Map, key: &str, values: &[String]) { + if !values.is_empty() { + out.insert(key.to_string(), json!(values)); + } +} + +fn insert_u32s(out: &mut Map, key: &str, values: &[u32]) { + if !values.is_empty() { + out.insert(key.to_string(), json!(values)); + } +} + +fn insert_values(out: &mut Map, key: &str, values: Vec) { + if !values.is_empty() { + out.insert(key.to_string(), Value::Array(values)); + } +} + +fn struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect(), + ) +} + +#[cfg(test)] +fn json_to_struct(value: &Value) -> Result { + let Value::Object(fields) = value else { + return Err("JSON value must be an object".to_string()); + }; + Ok(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }) +} + +fn json_to_proto_value(value: &Value) -> Result { + let kind = match value { + Value::Null => Kind::NullValue(0), + Value::Bool(value) => Kind::BoolValue(*value), + Value::Number(value) => Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| "invalid JSON number".to_string())?, + ), + Value::String(value) => Kind::StringValue(value.clone()), + Value::Array(values) => Kind::ListValue(ListValue { + values: values + .iter() + .map(json_to_proto_value) + .collect::>()?, + }), + Value::Object(fields) => Kind::StructValue(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }), + }; + Ok(ProtoValue { kind: Some(kind) }) +} + +fn proto_value_to_json(value: &ProtoValue) -> Value { + match value.kind.as_ref() { + Some(Kind::NullValue(_)) | None => Value::Null, + Some(Kind::NumberValue(value)) => { + Number::from_f64(*value).map_or(Value::Null, Value::Number) + } + Some(Kind::StringValue(value)) => Value::String(value.clone()), + Some(Kind::BoolValue(value)) => Value::Bool(*value), + Some(Kind::StructValue(value)) => struct_to_json(value), + Some(Kind::ListValue(value)) => { + Value::Array(value.values.iter().map(proto_value_to_json).collect()) + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut listen: SocketAddr = "127.0.0.1:18081".parse()?; + let mut policy_path: Option = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--listen" => { + let value = args.next().ok_or("--listen requires an address")?; + listen = value.parse()?; + } + "--policy" => { + let value = args.next().ok_or("--policy requires a path")?; + policy_path = Some(PathBuf::from(value)); + } + "-h" | "--help" => { + println!("usage: governance-interceptor [--listen ADDR] [--policy FILE]"); + return Ok(()); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + + let policy_yaml = if let Some(path) = policy_path { + tokio::fs::read_to_string(path).await? + } else { + include_str!("../policy.yaml").to_string() + }; + let service = GovernanceInterceptorService::from_yaml(&policy_yaml)?; + + println!("governance interceptor listening on {listen}"); + Server::builder() + .add_service(GatewayInterceptorServer::new(service)) + .serve(listen) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> GovernanceInterceptorService { + GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml")).unwrap() + } + + fn evaluation( + method: &str, + phase: GatewayInterceptorPhase, + operation: Value, + ) -> InterceptorEvaluation { + InterceptorEvaluation { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + service: SERVICE.to_string(), + method: method.to_string(), + phase: phase as i32, + operation: Some(json_to_struct(&operation).unwrap()), + current_state: Some(Struct::default()), + principal: HashMap::new(), + } + } + + #[test] + fn manifest_declares_governance_bindings() { + let manifest = GovernanceInterceptorService::manifest(); + let ids: Vec<_> = manifest + .bindings + .iter() + .map(|binding| binding.id.as_str()) + .collect(); + assert!(ids.contains(&"govern-create-sandbox")); + assert!(ids.contains(&"govern-attach-provider")); + assert!(ids.contains(&"govern-update-config")); + assert_eq!(manifest.failure_policy, "fail_closed"); + } + + #[test] + fn create_sandbox_modify_adds_policy_providers_and_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::ModifyOperation, + json!({"spec": {}, "labels": {"team": "platform"}}), + )) + .unwrap(); + assert!(result.allowed); + let paths: Vec<_> = result + .patches + .iter() + .map(|patch| patch.path.as_str()) + .collect(); + assert!(paths.contains(&"/spec/policy")); + assert!(paths.contains(&"/spec/providers")); + assert!(paths.contains(&"/labels/openshell.nvidia.com~1policy-signature")); + } + + #[test] + fn policy_patch_uses_protobuf_json_names() { + let service = service(); + assert!(service.policy.get("filesystem").is_some()); + assert!(service.policy.get("networkPolicies").is_some()); + assert!(service.policy.get("filesystem_policy").is_none()); + assert!(service.policy.get("network_policies").is_none()); + } + + #[test] + fn provider_creation_is_limited_to_governed_names() { + let service = service(); + let github = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "github"}}}), + )) + .unwrap(); + assert!(github.allowed); + + let slack = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}}}), + )) + .unwrap(); + assert!(!slack.allowed); + } + + #[test] + fn provider_attach_and_detach_are_denied() { + let service = service(); + for method in ["AttachSandboxProvider", "DetachSandboxProvider"] { + let result = service + .evaluate_inner(&evaluation( + method, + GatewayInterceptorPhase::Validate, + json!({"sandboxName": "demo", "providerName": "github"}), + )) + .unwrap(); + assert!(!result.allowed); + } + } + + #[test] + fn policy_update_and_merge_are_denied() { + let service = service(); + for operation in [ + json!({"name": "demo", "policy": {"version": 1}}), + json!({"name": "demo", "mergeOperations": [{"op": "add"}]}), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + } + } + + #[test] + fn governed_provider_update_and_delete_are_denied() { + let service = service(); + let update = service + .evaluate_inner(&evaluation( + "UpdateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "gitlab"}}}), + )) + .unwrap(); + assert!(!update.allowed); + + let delete = service + .evaluate_inner(&evaluation( + "DeleteProvider", + GatewayInterceptorPhase::Validate, + json!({"name": "github"}), + )) + .unwrap(); + assert!(!delete.allowed); + } +} diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto new file mode 100644 index 000000000..ec7164ab1 --- /dev/null +++ b/proto/gateway_interceptor.proto @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.gateway_interceptor.v1; + +import "google/protobuf/struct.proto"; + +// GatewayInterceptor lets an external governance service evaluate gateway +// control-plane operations after OpenShell admission and before or after the +// gateway applies the operation. +service GatewayInterceptor { + // Describe returns the interceptor manifest and declared bindings. + rpc Describe(DescribeRequest) returns (InterceptorManifest); + + // Evaluate returns an allow, deny, or mutation decision for one operation + // phase. + rpc Evaluate(InterceptorEvaluation) returns (InterceptorResult); +} + +message DescribeRequest {} + +enum GatewayInterceptorPhase { + GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; + GATEWAY_INTERCEPTOR_PHASE_PRE_REQUEST = 1; + GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; + GATEWAY_INTERCEPTOR_PHASE_VALIDATE = 3; + GATEWAY_INTERCEPTOR_PHASE_POST_COMMIT = 4; +} + +message InterceptorEvaluation { + // Configured interceptor instance name. + string interceptor_name = 1; + // Manifest binding id selected for this evaluation. + string binding_id = 2; + // Public gRPC service name, e.g. "openshell.v1.OpenShell". + string service = 3; + // Public gRPC method name, e.g. "CreateSandbox". + string method = 4; + // Evaluation phase. + GatewayInterceptorPhase phase = 5; + // Protobuf JSON-shaped operation payload. + google.protobuf.Struct operation = 6; + // Read-only gateway state relevant to the operation. + google.protobuf.Struct current_state = 7; + // Caller identity summary. Values are intentionally non-secret. + map principal = 8; +} + +message InterceptorResult { + // False denies the operation before side effects for pre_request, + // modify_operation, and validate. Post-commit denial is invalid. + bool allowed = 1; + // Human-readable reason for logs and denied gRPC status messages. + string reason = 2; + // Optional gRPC status code name for denials, e.g. "PERMISSION_DENIED". + string status_code = 3; + // RFC 6902 JSON patches. Only valid during modify_operation. + repeated JsonPatch patches = 4; + // Non-secret annotations included in gateway audit logs. + map audit_annotations = 5; +} + +message InterceptorManifest { + // Human-readable interceptor name declared by the service. + string name = 1; + // Bindings declared by the interceptor service. + repeated InterceptorBinding bindings = 2; + // Optional default failure policy for bindings without their own policy. + string failure_policy = 3; +} + +message InterceptorBinding { + // Stable binding id used for config overrides and audit logs. + string id = 1; + // RPC selector. Selectors are intentionally tied to the public API shape. + InterceptorSelector selector = 2; + // Phases this binding wants to evaluate. + repeated GatewayInterceptorPhase phases = 3; + // Optional binding-specific failure policy. + string failure_policy = 4; +} + +message InterceptorSelector { + // Full selector form: "openshell.v1.OpenShell/CreateSandbox". + string rpc = 1; + // Structured service/method form. If rpc is set, it takes precedence. + string service = 2; + string method = 3; +} + +message JsonPatch { + string op = 1; + string path = 2; + google.protobuf.Value value = 3; + string from = 4; +} From 94dc876ea331673ed15aa0b7af4d730d5d4acb5d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:36:04 -0700 Subject: [PATCH 02/12] 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 f56bb7151..b0ff2617b 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(), @@ -4764,6 +4772,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, @@ -5695,6 +5704,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, @@ -9600,6 +9610,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, + annotations: std::collections::HashMap::new(), }; let provider = Provider { diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4..24ab5e4bb 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,6 +63,7 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -349,6 +350,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a6e53eb1..101210d79 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -127,6 +127,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + annotations: HashMap::new(), }), spec: None, status: None, @@ -602,6 +603,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index ec8bd5374..3dec8943f 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 8e799f821..d8f8e695f 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 d673ade26..dab9fbd2a 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -597,7 +597,6 @@ pub struct GatewayInterceptorBindingOverride { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum GatewayInterceptorPhaseConfig { - PreRequest, ModifyOperation, Validate, PostCommit, diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 2460c499b..c310dee18 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -66,7 +66,6 @@ pub type Result = std::result::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Phase { - PreRequest, ModifyOperation, Validate, PostCommit, @@ -76,7 +75,6 @@ impl Phase { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::PreRequest => "pre_request", Self::ModifyOperation => "modify_operation", Self::Validate => "validate", Self::PostCommit => "post_commit", @@ -86,7 +84,6 @@ impl Phase { #[must_use] pub const fn to_proto(self) -> GatewayInterceptorPhase { match self { - Self::PreRequest => GatewayInterceptorPhase::PreRequest, Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, Self::Validate => GatewayInterceptorPhase::Validate, Self::PostCommit => GatewayInterceptorPhase::PostCommit, @@ -99,7 +96,6 @@ impl TryFrom for Phase { fn try_from(value: GatewayInterceptorPhase) -> Result { match value { - GatewayInterceptorPhase::PreRequest => Ok(Self::PreRequest), GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), GatewayInterceptorPhase::Validate => Ok(Self::Validate), GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), @@ -113,7 +109,6 @@ impl TryFrom for Phase { impl From for Phase { fn from(value: GatewayInterceptorPhaseConfig) -> Self { match value { - GatewayInterceptorPhaseConfig::PreRequest => Self::PreRequest, GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, GatewayInterceptorPhaseConfig::Validate => Self::Validate, GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, @@ -350,14 +345,9 @@ impl GatewayInterceptorRuntime { }; self.routes .is_interceptable(&selector.service, &selector.method) - && [ - Phase::PreRequest, - Phase::ModifyOperation, - Phase::Validate, - Phase::PostCommit, - ] - .iter() - .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + && [Phase::ModifyOperation, Phase::Validate, Phase::PostCommit] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) } pub async fn evaluate_request( @@ -379,9 +369,6 @@ impl GatewayInterceptorRuntime { .decode_message_to_json(&input_type, &frame.message) .map_err(|err| Status::invalid_argument(err.to_string()))?; - operation = self - .evaluate_phase(&selector, Phase::PreRequest, operation, context) - .await?; operation = self .evaluate_phase(&selector, Phase::ModifyOperation, operation, context) .await?; @@ -1875,6 +1862,7 @@ mod tests { }), name: "demo".to_string(), labels: HashMap::from([("team".to_string(), "agent".to_string())]), + annotations: HashMap::new(), }; let bytes = request.encode_to_vec(); let json = descriptors diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index fec29f0c4..a083faf11 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, @@ -2396,6 +2397,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() }; @@ -2411,6 +2413,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}"), @@ -3556,6 +3559,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 88c771bed..944b9b3ed 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,6 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c..a71274e2d 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -115,6 +115,8 @@ const MAX_MAP_VALUE_LEN: usize = 8192; const MAX_TEMPLATE_STRING_LEN: usize = 1024; /// Maximum number of entries in template map fields. const MAX_TEMPLATE_MAP_ENTRIES: usize = 128; +/// Maximum number of entries in metadata annotations. +const MAX_METADATA_ANNOTATIONS_ENTRIES: usize = 128; /// Maximum serialized size (bytes) for template Struct fields. const MAX_TEMPLATE_STRUCT_SIZE: usize = 65_536; /// Maximum serialized size (bytes) for the policy field. diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc8ff0d2e..53b11891f 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4063,6 +4063,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4096,6 +4097,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4128,6 +4130,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4163,6 +4166,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4250,6 +4254,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4330,6 +4335,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, @@ -4356,6 +4362,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())) @@ -4399,6 +4406,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4470,6 +4478,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(), @@ -4514,6 +4523,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(), @@ -4579,6 +4589,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(), @@ -4845,6 +4856,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(), @@ -5129,6 +5141,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(), @@ -5530,6 +5543,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), @@ -5619,6 +5633,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, @@ -5723,6 +5738,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, @@ -5938,6 +5954,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, @@ -6034,6 +6051,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 { @@ -6149,6 +6167,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 { @@ -6352,6 +6371,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 { @@ -6449,6 +6469,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 { @@ -6554,6 +6575,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 { @@ -6647,6 +6669,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 { @@ -6732,6 +6755,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 { @@ -6819,6 +6843,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 { @@ -6906,6 +6931,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 { @@ -6998,6 +7024,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 { @@ -7172,6 +7199,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 { @@ -7268,6 +7296,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 { @@ -7353,6 +7382,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 { @@ -7447,6 +7477,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(), @@ -7485,6 +7516,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7607,6 +7639,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 { @@ -7793,6 +7826,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, @@ -7907,6 +7941,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, @@ -8007,6 +8042,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, @@ -8121,6 +8157,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, @@ -8137,6 +8174,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, @@ -9746,6 +9784,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 @@ -9955,6 +9994,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10036,6 +10076,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c90..a13920518 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -78,6 +78,7 @@ pub(super) async fn create_provider_record( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); } @@ -1812,6 +1813,7 @@ fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), profile: Some(profile), } @@ -2146,6 +2148,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2241,6 +2244,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2526,6 +2530,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -2603,6 +2608,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2918,6 +2924,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2980,6 +2987,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: [ @@ -3589,6 +3597,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3624,6 +3633,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3736,6 +3746,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3799,6 +3810,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3842,6 +3854,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -3894,6 +3907,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3916,6 +3930,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) @@ -3935,6 +3950,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -3986,6 +4002,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), @@ -4005,6 +4022,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4071,6 +4089,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4138,6 +4157,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4289,6 +4309,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4411,6 +4432,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4455,6 +4477,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4484,6 +4507,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4518,6 +4542,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4538,6 +4563,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), @@ -4612,6 +4638,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), @@ -4648,6 +4675,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), @@ -4684,6 +4712,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), @@ -4704,6 +4733,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), @@ -4730,6 +4760,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4758,6 +4789,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4805,6 +4837,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), @@ -4856,6 +4889,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4885,6 +4919,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: HashMap::new(), @@ -4916,6 +4951,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), @@ -4944,6 +4980,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), @@ -4961,6 +4998,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) @@ -4992,6 +5030,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: [ @@ -5046,6 +5085,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5095,6 +5135,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5129,6 +5170,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5151,6 +5193,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) @@ -5184,6 +5227,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) @@ -5203,6 +5247,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5241,6 +5286,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5315,6 +5361,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5359,6 +5406,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5407,6 +5455,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5454,6 +5503,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -5492,6 +5542,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5514,6 +5565,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5534,6 +5586,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5552,6 +5605,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5586,6 +5640,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5607,6 +5662,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5643,6 +5699,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5690,6 +5747,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), @@ -6037,6 +6095,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), @@ -6139,6 +6198,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "github".to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed5..509b460d5 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(), @@ -2253,6 +2256,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())) @@ -2270,6 +2274,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(), @@ -2640,6 +2645,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2672,6 +2678,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2682,6 +2689,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; @@ -2703,6 +2777,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 246d639be..01d8dbfe8 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -87,6 +87,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -286,6 +287,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 09e9f1cad..46122fdd8 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -15,10 +15,10 @@ use prost::Message; use tonic::Status; use super::{ - MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_NAME_LEN, - MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, MAX_PROVIDER_CREDENTIALS_ENTRIES, - MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, - MAX_TEMPLATE_STRUCT_SIZE, + MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, + MAX_METADATA_ANNOTATIONS_ENTRIES, MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, + MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, + MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, }; // --------------------------------------------------------------------------- @@ -257,6 +257,28 @@ pub(super) fn validate_string_map( Ok(()) } +/// Validate object annotations. +/// +/// Annotation keys use the same qualified-key shape as labels. Annotation +/// values are opaque metadata and use the normal string-map size limits rather +/// than Kubernetes label value limits. +pub(super) fn validate_annotations( + annotations: &std::collections::HashMap, + field_name: &str, +) -> Result<(), Status> { + validate_string_map( + annotations, + MAX_METADATA_ANNOTATIONS_ENTRIES, + MAX_MAP_KEY_LEN, + MAX_MAP_VALUE_LEN, + field_name, + )?; + for key in annotations.keys() { + validate_label_key(key)?; + } + Ok(()) +} + /// OPENSHELL_* keys that are allowed in exec environment. The Python SDK's /// `exec_python()` sends a serialized callable via this key. const EXEC_ALLOWED_OPENSHELL_KEYS: &[&str] = &["OPENSHELL_PYFUNC_B64"]; @@ -616,6 +638,11 @@ pub(super) fn validate_object_metadata( validate_label_value(value)?; } + validate_annotations( + &metadata.annotations, + &format!("{resource_type}.metadata.annotations"), + )?; + Ok(()) } @@ -1147,6 +1174,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials, diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 43416c35d..2b58bd15c 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -211,6 +211,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -1030,6 +1031,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: provider_name.to_string(), @@ -1048,6 +1050,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), @@ -1145,6 +1148,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1221,6 +1225,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1269,6 +1274,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1494,6 +1500,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1517,6 +1524,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: "openai-dev".to_string(), @@ -1628,6 +1636,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1965,6 +1974,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index d092b68de..91a24f373 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1243,6 +1243,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, @@ -1282,6 +1283,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c..b604a3ef3 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -200,6 +200,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), provider_id, provider_name, @@ -924,6 +925,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -1172,6 +1174,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba9..1dabb7744 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -804,6 +804,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 752fee1c0..4c6589b9f 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -86,6 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 4adf9e8b6..16ddbc0d4 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,6 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d3..dc5ead7c0 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1360,6 +1360,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }; let sandbox_name = @@ -1615,6 +1616,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype.clone(), credentials: credentials.clone(), @@ -1707,6 +1709,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype, credentials, diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c794546c7..351ffb876 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -144,7 +144,7 @@ max_patches = 32 [[openshell.gateway.interceptors.bindings]] id = "quota-create-sandbox" -phases = ["pre_request", "validate"] +phases = ["modify_operation", "validate"] failure_policy = "fail_closed" [[openshell.gateway.interceptors.bindings]] diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 6a8a15820..6ca15e7f6 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -49,6 +49,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "autotools" version = "0.2.7" @@ -146,6 +152,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" @@ -203,6 +215,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -341,8 +359,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -642,6 +662,32 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.186" @@ -759,6 +805,40 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.37.3" @@ -800,9 +880,12 @@ name = "openshell-governance-interceptor-example" version = "0.0.0" dependencies = [ "base64", + "jsonwebtoken", "openshell-core", "openshell-policy", "prost-types", + "rcgen", + "serde", "serde_json", "sha2", "tokio", @@ -855,6 +938,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -907,6 +1000,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1023,6 +1122,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1140,6 +1252,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -1269,6 +1387,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1405,6 +1535,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1696,6 +1856,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1790,6 +1995,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index ada1f5ecf..a8ff9106c 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -12,9 +12,12 @@ license = "Apache-2.0" [dependencies] base64 = "0.22" +jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } openshell-policy = { path = "../../crates/openshell-policy" } prost-types = "0.14" +rcgen = { version = "0.13", features = ["crypto", "pem"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index c2f93a02e..0f400a95c 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -4,20 +4,33 @@ This standalone example implements the `openshell.gateway_interceptor.v1.Gateway - every new sandbox receives `policy.yaml` - every new sandbox is attached to exactly `github` and `gitlab` -- every new sandbox gets an `openshell.nvidia.com/policy-signature` label +- every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` - users cannot update or delete the governed `github` or `gitlab` provider records +Run these commands from `examples/governance-interceptor`. + Run the interceptor: ```shell -cargo run --manifest-path examples/governance-interceptor/Cargo.toml -- \ +cargo run -- \ --listen 127.0.0.1:18081 \ - --policy examples/governance-interceptor/policy.yaml + --policy policy.yaml ``` +At startup the example parses `policy.yaml`, converts it to the protobuf JSON +shape used by sandbox creation, computes a canonical SHA-256 digest, and signs +that digest as an EdDSA JWT. The interceptor adds that JWT to each governed +sandbox under `metadata.annotations["openshell.nvidia.com/policy-signature"]` and +verifies the JWT against the sandbox policy during the `CreateSandbox` validate +phase. + +The signing key is generated in memory on each interceptor start. This keeps the +example self-contained. Production governance services should load managed +signing keys, publish verifier keys, and define a rotation process. + Gateway TOML snippet: ```toml @@ -34,31 +47,7 @@ max_patches = 32 Run the smoke test against a local gateway and compute driver: ```shell -examples/governance-interceptor/smoke.sh +./smoke.sh ``` The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. - -Set `OPENSHELL_GOVERNANCE_SMOKE_DRIVER=docker|podman|vm|kubernetes` to force a driver. Without it, the gateway uses its existing local driver detection. - -On macOS the smoke script uses `clang`/`clang++` for native dependencies, because Apple SDK headers require Clang block syntax. It also disables `RUSTC_WRAPPER` by default so local `sccache` configuration does not affect the smoke run. - -The workspace build requires Z3. The smoke script uses `pkg-config`, `brew --prefix z3`, `/opt/homebrew/opt/z3`, or `/usr/local/opt/z3` when those locations contain `include/z3.h` and a `lib` directory. If no usable local Z3 install exists, install it first: - -```shell -brew install z3 -``` - -Build overrides: - -```shell -OPENSHELL_GOVERNANCE_CC=/path/to/clang \ -OPENSHELL_GOVERNANCE_CXX=/path/to/clang++ \ -OPENSHELL_GOVERNANCE_RUSTC_WRAPPER=sccache \ -Z3_SYS_Z3_HEADER=/path/to/include/z3.h \ -Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib \ -examples/governance-interceptor/smoke.sh -``` - -Set `OPENSHELL_GOVERNANCE_KEEP_CC=1` or `OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER=1` to preserve the caller environment. -Set `OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1` to opt into the bundled Z3 build, which downloads source metadata from GitHub and can fail in offline or rate-limited environments. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 52681f978..ff967fdcf 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -15,7 +15,6 @@ GATEWAY_LOG="$LOG_DIR/gateway.log" INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" -DRIVER="${OPENSHELL_GOVERNANCE_SMOKE_DRIVER:-}" SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" ROOT_BUILD_ARGS=() mkdir -p "$LOG_DIR" @@ -189,11 +188,6 @@ run_step "build governance interceptor" cargo build --quiet --manifest-path "$EX --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & INTERCEPTOR_PID=$! -driver_line="" -if [[ -n "$DRIVER" ]]; then - driver_line="compute_drivers = [\"$DRIVER\"]" -fi - cat > "$TMPDIR/gateway.toml" <) -> std::fmt::Result { + f.debug_struct("PolicySigner") + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct PolicySignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + policy_sha256: String, +} + +impl PolicySigner { + fn generate() -> Result { + let keypair = KeyPair::generate_for(&PKCS_ED25519) + .map_err(|err| format!("failed to generate policy signing key: {err}"))?; + let signing_key_pem = keypair.serialize_pem(); + let public_key_pem = keypair.public_key_pem(); + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy signing key: {err}"))?; + let decoding_key = DecodingKey::from_ed_pem(public_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy verification key: {err}"))?; + let kid = kid_from_public_key_der(&keypair.public_key_der()); + Ok(Self { + encoding_key, + decoding_key, + kid, + }) + } + + fn kid(&self) -> &str { + &self.kid + } + + fn sign_policy(&self, policy_hash: &str) -> Result { + let claims = PolicySignatureClaims { + sub: POLICY_JWT_SUBJECT.to_string(), + iss: POLICY_JWT_ISSUER.to_string(), + aud: POLICY_JWT_AUDIENCE.to_string(), + iat: now_secs(), + exp: 0, + policy_sha256: policy_hash.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + encode(&header, &claims, &self.encoding_key) + .map_err(|err| format!("failed to sign policy JWT: {err}")) + } + + fn verify_policy_signature(&self, token: &str, policy_hash: &str) -> Result<(), String> { + let header = decode_header(token) + .map_err(|err| format!("failed to decode policy JWT header: {err}"))?; + if header.kid.as_deref() != Some(self.kid.as_str()) { + return Err("unexpected policy signing key id".to_string()); + } + if header.alg != Algorithm::EdDSA { + return Err("unexpected policy signing algorithm".to_string()); + } + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.algorithms = vec![Algorithm::EdDSA]; + validation.set_issuer(&[POLICY_JWT_ISSUER]); + validation.set_audience(&[POLICY_JWT_AUDIENCE]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + validation.validate_exp = false; + + let data = decode::(token, &self.decoding_key, &validation) + .map_err(|err| format!("failed to verify policy JWT: {err}"))?; + if data.claims.sub != POLICY_JWT_SUBJECT { + return Err("unexpected policy JWT subject".to_string()); + } + if data.claims.policy_sha256 != policy_hash { + return Err("signed policy hash does not match sandbox policy".to_string()); + } + Ok(()) + } +} + #[derive(Clone, Debug)] struct GovernanceInterceptorService { policy: Value, + policy_hash: String, policy_signature: String, + policy_signer: PolicySigner, } impl GovernanceInterceptorService { @@ -38,15 +140,14 @@ impl GovernanceInterceptorService { .map_err(|err| format!("failed to parse policy YAML: {err}"))?; let policy = sandbox_policy_to_proto_json(&policy); let policy = normalize_for_struct(policy)?; - let policy_digest: [u8; 32] = Sha256::digest( - serde_json::to_vec(&policy) - .map_err(|err| format!("failed to encode policy JSON: {err}"))?, - ) - .into(); - let policy_signature = format!("sha256-{}", URL_SAFE_NO_PAD.encode(policy_digest)); + let policy_hash = policy_hash(&policy)?; + let policy_signer = PolicySigner::generate()?; + let policy_signature = policy_signer.sign_policy(&policy_hash)?; Ok(Self { policy, + policy_hash, policy_signature, + policy_signer, }) } @@ -158,43 +259,49 @@ impl GovernanceInterceptorService { )?); } - if operation.get("labels").is_some_and(Value::is_object) { - patches.push(json_patch( - "add", - &format!("/labels/{}", json_pointer_escape(LABEL_KEY)), - Value::String(self.policy_signature.clone()), - )?); - } else { - patches.push(json_patch( - "add", - "/labels", - json!({ LABEL_KEY: self.policy_signature }), - )?); - } + add_policy_signature_patches(operation, &mut patches, &self.policy_signature)?; let mut result = allow(); result.patches = patches; + result + .audit_annotations + .insert("policy_hash".to_string(), self.policy_hash.clone()); result.audit_annotations.insert( - "policy_signature".to_string(), - self.policy_signature.clone(), + "policy_signature_kid".to_string(), + self.policy_signer.kid().to_string(), ); Ok(result) } fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { - if operation.pointer("/spec/policy") != Some(&self.policy) { + let Some(policy) = operation.pointer("/spec/policy") else { + return deny("sandbox policy must match the source-control governance baseline"); + }; + let sandbox_policy_hash = match policy_hash(policy) { + Ok(hash) => hash, + Err(err) => return deny(&format!("sandbox policy cannot be hashed: {err}")), + }; + let Some(signature) = operation + .pointer(&format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + else { + return deny("sandbox is missing the governance policy signature"); + }; + if let Err(err) = self + .policy_signer + .verify_policy_signature(signature, &sandbox_policy_hash) + { + return deny(&format!("sandbox policy signature is invalid: {err}")); + } + if sandbox_policy_hash != self.policy_hash || policy != &self.policy { return deny("sandbox policy must match the source-control governance baseline"); } if !providers_are_governed(operation.pointer("/spec/providers")) { return deny("sandbox providers must be exactly github and gitlab"); } - if operation - .pointer(&format!("/labels/{}", json_pointer_escape(LABEL_KEY))) - .and_then(Value::as_str) - != Some(self.policy_signature.as_str()) - { - return deny("sandbox is missing the governance policy signature label"); - } allow() } } @@ -329,6 +436,33 @@ fn json_patch(op: &str, path: &str, value: Value) -> Result { }) } +fn add_policy_signature_patches( + operation: &Value, + patches: &mut Vec, + policy_signature: &str, +) -> Result<(), Status> { + let signature = Value::String(policy_signature.to_string()); + if operation.get("annotations").is_none_or(|value| !value.is_object()) { + patches.push(json_patch( + "add", + "/annotations", + json!({ + POLICY_SIGNATURE_ANNOTATION: policy_signature, + }), + )?); + } else { + patches.push(json_patch( + "add", + &format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + ), + signature, + )?); + } + Ok(()) +} + fn json_pointer_escape(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } @@ -337,6 +471,38 @@ fn normalize_for_struct(value: Value) -> Result { json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) } +fn policy_hash(policy: &Value) -> Result { + let policy = normalize_for_struct(policy.clone())?; + let encoded = serde_json::to_vec(&policy) + .map_err(|err| format!("failed to encode policy JSON: {err}"))?; + let digest: [u8; 32] = Sha256::digest(encoded).into(); + Ok(format!("sha256-{}", URL_SAFE_NO_PAD.encode(digest))) +} + +fn kid_from_public_key_der(public_key_der: &[u8]) -> String { + let digest = Sha256::digest(public_key_der); + hex_encode_prefix(&digest, 16) +} + +fn hex_encode_prefix(bytes: &[u8], n: usize) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(n * 2); + for byte in bytes.iter().take(n) { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn now_secs() -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) + .unwrap_or(i64::MAX) +} + fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Value { let mut out = Map::new(); out.insert("version".to_string(), json!(policy.version)); @@ -679,6 +845,53 @@ mod tests { } } + fn governed_create_operation(policy: Value, signature: String) -> Value { + let mut operation = json!({ + "spec": { + "policy": policy, + "providers": GOVERNED_PROVIDERS, + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(signature), + ); + operation + } + + fn valid_create_operation(service: &GovernanceInterceptorService) -> Value { + governed_create_operation(service.policy.clone(), service.policy_signature.clone()) + } + + fn signature_patch_token(result: &InterceptorResult) -> String { + result + .patches + .iter() + .find(|patch| { + patch.path == "/annotations/openshell.nvidia.com~1policy-signature" + || patch.path == "/annotations" + }) + .and_then(|patch| patch.value.as_ref()) + .map(proto_value_to_json) + .and_then(|value| { + value.as_str().map(ToString::to_string).or_else(|| { + value + .pointer(&format!( + "/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + .map(ToString::to_string) + }) + }) + .expect("signature patch value") + } + #[test] fn manifest_declares_governance_bindings() { let manifest = GovernanceInterceptorService::manifest(); @@ -711,7 +924,99 @@ mod tests { .collect(); assert!(paths.contains(&"/spec/policy")); assert!(paths.contains(&"/spec/providers")); - assert!(paths.contains(&"/labels/openshell.nvidia.com~1policy-signature")); + assert!( + paths.contains(&"/annotations") + || paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature") + ); + let token = signature_patch_token(&result); + assert_eq!(token.split('.').count(), 3); + assert!(result.audit_annotations.contains_key("policy_hash")); + assert!( + result + .audit_annotations + .contains_key("policy_signature_kid") + ); + assert!(!result.audit_annotations.contains_key("policy_signature")); + } + + #[test] + fn create_sandbox_validate_accepts_signed_policy() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + valid_create_operation(&service), + )) + .unwrap(); + assert!(result.allowed); + } + + #[test] + fn create_sandbox_validate_denies_missing_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + json!({ + "spec": { + "policy": service.policy, + "providers": GOVERNED_PROVIDERS, + }, + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("missing")); + } + + #[test] + fn create_sandbox_validate_denies_malformed_signature() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(service.policy.clone(), "not-a-jwt".to_string()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); + } + + #[test] + fn create_sandbox_validate_denies_signature_from_other_key() { + let governance = service(); + let other = service(); + let result = governance + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(governance.policy.clone(), other.policy_signature), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); + } + + #[test] + fn create_sandbox_validate_denies_signed_policy_mismatch() { + let service = service(); + let mut tampered_policy = service.policy.clone(); + tampered_policy + .as_object_mut() + .unwrap() + .insert("version".to_string(), json!(999)); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(tampered_policy, service.policy_signature.clone()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); } #[test] diff --git a/proto/datamodel.proto b/proto/datamodel.proto index f92d7b7a3..cfd6e63c4 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -7,8 +7,9 @@ package openshell.datamodel.v1; // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // -// This structure provides consistent metadata (identity, labels, timestamps, -// resource versioning) across Sandbox, Provider, SshSession, and other resources. +// This structure provides consistent metadata (identity, labels, annotations, +// timestamps, resource versioning) across Sandbox, Provider, SshSession, and +// other resources. message ObjectMeta { // Stable object ID generated by the gateway. string id = 1; @@ -26,6 +27,10 @@ message ObjectMeta { // Optimistic concurrency control version. // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; + + // Opaque key-value metadata that is not used for selectors. + // Annotation keys use the same qualified-key shape as labels, but values may be longer. + map annotations = 6; } // Provider model stored by OpenShell. diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index ec7164ab1..efa8bc88a 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -23,7 +23,6 @@ message DescribeRequest {} enum GatewayInterceptorPhase { GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; - GATEWAY_INTERCEPTOR_PHASE_PRE_REQUEST = 1; GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; GATEWAY_INTERCEPTOR_PHASE_VALIDATE = 3; GATEWAY_INTERCEPTOR_PHASE_POST_COMMIT = 4; @@ -49,8 +48,8 @@ message InterceptorEvaluation { } message InterceptorResult { - // False denies the operation before side effects for pre_request, - // modify_operation, and validate. Post-commit denial is invalid. + // False denies the operation before side effects for modify_operation and + // validate. Post-commit denial is invalid. bool allowed = 1; // Human-readable reason for logs and denied gRPC status messages. string reason = 2; diff --git a/proto/openshell.proto b/proto/openshell.proto index bf803e864..5fdb6970c 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -447,6 +447,8 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // Optional annotations for the sandbox (non-selector metadata). + map annotations = 4; } // Get sandbox request. From 12746bd771d10aa0eba3de72a85800f1f878124f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 16:38:51 -0700 Subject: [PATCH 03/12] wip --- examples/governance-interceptor/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 0f400a95c..20e52f3d4 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -10,8 +10,6 @@ This standalone example implements the `openshell.gateway_interceptor.v1.Gateway - users cannot create provider records other than `github` and `gitlab` - users cannot update or delete the governed `github` or `gitlab` provider records -Run these commands from `examples/governance-interceptor`. - Run the interceptor: ```shell From 947c911e694133a18bc668e693f1b9e549805e41 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 24 Jun 2026 18:57:12 -0700 Subject: [PATCH 04/12] fix(gateway): harden interceptor evaluation Signed-off-by: Drew Newberry --- .../openshell-gateway-interceptors/src/lib.rs | 249 +++++++++++++++--- crates/openshell-server/src/multiplex.rs | 42 ++- 2 files changed, 251 insertions(+), 40 deletions(-) diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index c310dee18..1b7b0e7b1 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -244,31 +244,37 @@ impl GatewayInterceptorRuntime { for config in configs { validate_service_config(&config)?; let channel = connect_endpoint(&config.grpc_endpoint).await?; + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; let mut client = GatewayInterceptorClient::new(channel.clone()) .max_decoding_message_size( config .max_response_bytes .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), ); - let manifest = client - .describe(Request::new(DescribeRequest {})) - .await - .map_err(|status| { - InterceptorError::Transport(format!( - "Describe failed for '{}': {status}", - config.name - )) - })? - .into_inner(); + let manifest = + tokio::time::timeout(timeout, client.describe(Request::new(DescribeRequest {}))) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "Describe timed out for '{}'", + config.name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); let service_default = config .failure_policy .map(FailurePolicy::from) .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) .unwrap_or(FailurePolicy::FailClosed); - let timeout = match config.timeout.as_deref() { - Some(timeout) => parse_duration(timeout)?, - None => DEFAULT_TIMEOUT, - }; let max_response_bytes = config .max_response_bytes .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); @@ -448,12 +454,15 @@ impl GatewayInterceptorRuntime { if phase == Phase::ModifyOperation && !result.patches.is_empty() { let patch_count = result.patches.len(); - let patch_ops = json_patch_operations(&result.patches) - .map_err(|err| Status::invalid_argument(err.to_string()))?; - patch(&mut operation, &patch_ops).map_err(|err| { - Status::invalid_argument(format!("invalid JSON patch: {err}")) - })?; - emit_evaluation_metrics(plan, "allow", patch_count); + match apply_json_patches(&operation, &result.patches) { + Ok(patched) => { + operation = patched; + emit_evaluation_metrics(plan, "allow", patch_count); + } + Err(err) => { + apply_failure_policy(plan, &err)?; + } + } } else { emit_evaluation_metrics(plan, "allow", 0); } @@ -885,6 +894,14 @@ fn json_patch_operations(patches: &[JsonPatch]) -> Result> { .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) } +fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result { + let patch_ops = json_patch_operations(patches)?; + let mut candidate = operation.clone(); + patch(&mut candidate, &patch_ops) + .map_err(|err| InterceptorError::InvalidResult(format!("invalid JSON patch: {err}")))?; + Ok(candidate) +} + fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { counter!("openshell_gateway_interceptor_evaluations_total").increment(1); if patch_count > 0 { @@ -1041,6 +1058,10 @@ impl ProtoDescriptors { } fn decode_message_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + if let Some(value) = decode_well_known_json(type_name, bytes) { + return value; + } + let message = self.message(type_name)?; let mut values: HashMap> = HashMap::new(); let mut input = bytes; @@ -1243,6 +1264,10 @@ impl ProtoDescriptors { } fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + if let Some(encoded) = encode_well_known_json(type_name, value) { + return encoded; + } + let message = self.message(type_name)?; let Value::Object(map) = value else { return Err(InterceptorError::Transcode(format!( @@ -1589,6 +1614,20 @@ fn json_to_struct(value: Value) -> Result { } } +fn json_to_list_value(value: Value) -> Result { + match value { + Value::Array(values) => Ok(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "google.protobuf.ListValue JSON must be an array".to_string(), + )), + } +} + fn json_to_protobuf_value(value: Value) -> Result { let kind = match value { Value::Null => prost_types::value::Kind::NullValue(0), @@ -1615,22 +1654,76 @@ fn json_to_protobuf_value(value: Value) -> Result { Ok(prost_types::Value { kind: Some(kind) }) } +fn decode_well_known_json(type_name: &str, bytes: &[u8]) -> Option> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => Some( + Struct::decode(bytes) + .map(|value| protobuf_struct_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Struct bytes: {err}" + )) + }), + ), + "google.protobuf.Value" => Some( + prost_types::Value::decode(bytes) + .map(|value| protobuf_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Value bytes: {err}" + )) + }), + ), + "google.protobuf.ListValue" => Some( + prost_types::ListValue::decode(bytes) + .map(|value| protobuf_list_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.ListValue bytes: {err}" + )) + }), + ), + _ => None, + } +} + +fn encode_well_known_json(type_name: &str, value: &Value) -> Option>> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => { + Some(json_to_struct(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.Value" => { + Some(json_to_protobuf_value(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.ListValue" => { + Some(json_to_list_value(value.clone()).map(|value| value.encode_to_vec())) + } + _ => None, + } +} + +fn protobuf_struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) + .collect(), + ) +} + +fn protobuf_list_value_to_json(value: &prost_types::ListValue) -> Value { + Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) +} + fn protobuf_value_to_json(value: &prost_types::Value) -> Value { match value.kind.as_ref() { Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, Some(prost_types::value::Kind::NumberValue(value)) => number_json(*value), Some(prost_types::value::Kind::StringValue(value)) => Value::String(value.clone()), Some(prost_types::value::Kind::BoolValue(value)) => Value::Bool(*value), - Some(prost_types::value::Kind::StructValue(value)) => Value::Object( - value - .fields - .iter() - .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) - .collect(), - ), - Some(prost_types::value::Kind::ListValue(value)) => { - Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) - } + Some(prost_types::value::Kind::StructValue(value)) => protobuf_struct_to_json(value), + Some(prost_types::value::Kind::ListValue(value)) => protobuf_list_value_to_json(value), } } @@ -1842,7 +1935,8 @@ fn json_u32(value: &Value, field: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::{CreateSandboxRequest, SandboxSpec}; + use openshell_core::proto::{CreateSandboxRequest, SandboxSpec, SandboxTemplate}; + use serde_json::json; #[test] fn parses_timeout_suffixes() { @@ -1876,4 +1970,97 @@ mod tests { let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); assert_eq!(decoded, request); } + + #[test] + fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + resources: Some( + json_to_struct(json!({ + "limits": { + "cpu": "2", + "memory": "4Gi" + } + })) + .unwrap(), + ), + driver_config: Some( + json_to_struct(json!({ + "docker": { + "userns": "host" + } + })) + .unwrap(), + ), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::new(), + annotations: HashMap::new(), + }; + + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + + assert_eq!(json["spec"]["template"]["resources"]["limits"]["cpu"], "2"); + assert_eq!( + json["spec"]["template"]["driverConfig"]["docker"]["userns"], + "host" + ); + assert!( + json["spec"]["template"]["resources"] + .get("fields") + .is_none() + ); + + let encoded = descriptors + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[tokio::test] + async fn invalid_modify_patch_honors_fail_open_without_mutating_operation() { + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailOpen, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let operation = json!({ "name": "demo" }); + let result = InterceptorResult { + allowed: true, + patches: vec![JsonPatch { + op: "replace".to_string(), + path: "/missing".to_string(), + value: Some(prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("value".to_string())), + }), + from: String::new(), + }], + ..InterceptorResult::default() + }; + + let err = apply_json_patches(&operation, &result.patches).unwrap_err(); + apply_failure_policy(&plan, &err).unwrap(); + assert_eq!(operation, json!({ "name": "demo" })); + } } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 49680ebf1..ee7cfaab5 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 24cadaade3f57b9b8d3163d5d9767ee7ca821dde Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 25 Jun 2026 09:10:10 -0700 Subject: [PATCH 05/12] feat(interceptors): label metrics and harden governance smoke Signed-off-by: Drew Newberry --- .../openshell-gateway-interceptors/src/lib.rs | 17 +- examples/governance-interceptor/README.md | 12 +- examples/governance-interceptor/smoke.sh | 220 +++++++++++++----- 3 files changed, 186 insertions(+), 63 deletions(-) diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 1b7b0e7b1..c8d82566f 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -902,10 +902,21 @@ fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result Ok(candidate) } -fn emit_evaluation_metrics(_plan: &BindingPlan, _result: &str, patch_count: usize) { - counter!("openshell_gateway_interceptor_evaluations_total").increment(1); +fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) { + counter!( + "openshell_gateway_interceptor_evaluations_total", + "decision" => result.to_string(), + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(1); if patch_count > 0 { - counter!("openshell_gateway_interceptor_patches_total").increment(patch_count as u64); + counter!( + "openshell_gateway_interceptor_patches_total", + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(patch_count as u64); } } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 20e52f3d4..c4ac7f1d3 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -1,10 +1,13 @@ # Governance Interceptor Example -This standalone example implements the `openshell.gateway_interceptor.v1.GatewayInterceptor` service. It enforces a source-control governance baseline: +This standalone example implements the +`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates how to +extend OpenShell to provide advanced governance over sandbox policies. -- every new sandbox receives `policy.yaml` +- every new sandbox receives `policy.yaml` sourced from this examples folder - every new sandbox is attached to exactly `github` and `gitlab` - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation + that is used to verify the policy - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` @@ -42,10 +45,9 @@ max_response_bytes = 1048576 max_patches = 32 ``` -Run the smoke test against a local gateway and compute driver: +Run the smoke test script to automatically start the gateway, interceptor, and test the +governance controls ```shell ./smoke.sh ``` - -The smoke test prints one `PASS` or `FAIL` line per case. Gateway, interceptor, build, and CLI logs are written to a temporary log directory and shown only if a case fails. Set `OPENSHELL_GOVERNANCE_KEEP_LOGS=1` or `OPENSHELL_GOVERNANCE_LOG_DIR=/path/to/logs` to keep logs after a successful run. diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index ff967fdcf..3ce6b6cc2 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -7,16 +7,48 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" EXAMPLE_DIR="$ROOT/examples/governance-interceptor" TMPDIR="$(mktemp -d)" +SMOKE_RUN_ID="${OPENSHELL_GOVERNANCE_RUN_ID:-governance-smoke-$$-$RANDOM}" +port_is_free() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + else + ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + fi +} + +choose_port_block() { + local count="$1" + local start offset ok + for _ in {1..200}; do + start=$((20000 + RANDOM % 20000)) + ok=1 + for ((offset = 0; offset < count; offset++)); do + if ! port_is_free "$((start + offset))"; then + ok=0 + break + fi + done + if [[ "$ok" == "1" ]]; then + printf '%s\n' "$start" + return + fi + done + echo "failed to find free local ports for smoke test" >&2 + exit 1 +} + +DEFAULT_PORT_BASE="$(choose_port_block 3)" JWT_DIR="$TMPDIR/jwt" LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" SMOKE_LOG="$LOG_DIR/smoke.log" INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" GATEWAY_LOG="$LOG_DIR/gateway.log" -INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:18081}" -GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:18080}" -HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:18082}" -SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-governed-smoke-$$}" -ROOT_BUILD_ARGS=() +INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:$DEFAULT_PORT_BASE}" +GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 1))}" +HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 2))}" +GATEWAY_ID="${OPENSHELL_GOVERNANCE_GATEWAY_ID:-$SMOKE_RUN_ID}" +SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-$SMOKE_RUN_ID-sandbox}" mkdir -p "$LOG_DIR" cleanup() { @@ -43,12 +75,47 @@ pass() { fail() { printf 'FAIL %s\n' "$1" >&2 - printf ' smoke log: %s\n' "$SMOKE_LOG" >&2 - printf ' gateway log: %s\n' "$GATEWAY_LOG" >&2 - printf ' interceptor log: %s\n' "$INTERCEPTOR_LOG" >&2 + dump_logs exit 1 } +setup_fail() { + printf 'ERROR %s\n' "$1" >&2 + dump_logs + exit 1 +} + +dump_log_file() { + local label="$1" + local path="$2" + printf '\n--- %s: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 + else + printf '(missing)\n' >&2 + fi +} + +dump_logs() { + dump_log_file "smoke log" "$SMOKE_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" +} + +run_setup_step() { + local label="$1" + shift + printf 'INFO %s\n' "$label" + { + printf '\n== %s ==\n' "$label" + printf '+ %q ' "$@" + printf '\n' + } >>"$SMOKE_LOG" + if ! "$@" >>"$SMOKE_LOG" 2>&1; then + setup_fail "$label" + fi +} + run_step() { local label="$1" shift @@ -97,20 +164,6 @@ expect_output_contains() { fi } -missing_z3() { - cat >&2 <<'EOF' -No usable local Z3 installation found. - -Install Z3 or point the build at an existing install, then rerun: - brew install z3 - Z3_SYS_Z3_HEADER=/path/to/include/z3.h Z3_LIBRARY_PATH_OVERRIDE=/path/to/lib examples/governance-interceptor/smoke.sh - -The bundled Z3 build downloads source metadata from GitHub and can fail in offline or rate-limited environments. -To opt into that path anyway, set OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3=1. -EOF - exit 1 -} - configure_native_build_env() { if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" @@ -129,38 +182,56 @@ configure_native_build_env() { fi } -configure_z3_build_env() { - if [[ -n "${Z3_SYS_Z3_HEADER:-}" || -n "${Z3_LIBRARY_PATH_OVERRIDE:-}" ]]; then - log "Using caller-provided Z3 build environment." +docker_socket_responds() { + local socket="$1" + curl --silent --fail --unix-socket "$socket" http://localhost/_ping >/dev/null 2>&1 +} + +docker_context_socket() { + if ! command -v docker >/dev/null 2>&1; then return fi - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3 >/dev/null 2>&1; then - log "Using pkg-config Z3 for workspace builds." + local endpoint + endpoint="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)" + if [[ "$endpoint" == unix://* ]]; then + printf '%s\n' "${endpoint#unix://}" + fi +} + +configure_container_runtime_env() { + if [[ -n "${DOCKER_HOST:-}" ]]; then + log "Using caller-provided DOCKER_HOST=$DOCKER_HOST" return fi - z3_prefix="" - if command -v brew >/dev/null 2>&1; then - z3_prefix="$(brew --prefix z3 2>/dev/null || true)" + local candidate + local -a candidates=() + + candidate="$(docker_context_socket)" + if [[ -n "$candidate" ]]; then + candidates+=("$candidate") + fi + + if [[ -n "${HOME:-}" ]]; then + candidates+=( + "$HOME/.colima/default/docker.sock" + "$HOME/.colima/docker.sock" + "$HOME/.docker/run/docker.sock" + ) fi - for candidate in "$z3_prefix" /opt/homebrew/opt/z3 /usr/local/opt/z3; do - if [[ -n "$candidate" && -f "$candidate/include/z3.h" && -d "$candidate/lib" ]]; then - log "Using local Z3 from ${candidate} for workspace builds." - export Z3_SYS_Z3_HEADER="${candidate}/include/z3.h" - export Z3_LIBRARY_PATH_OVERRIDE="${candidate}/lib" + candidates+=("/var/run/docker.sock") + + for candidate in "${candidates[@]}"; do + if [[ -S "$candidate" ]] && docker_socket_responds "$candidate"; then + export DOCKER_HOST="unix://$candidate" + log "Using Docker socket from $DOCKER_HOST for workspace builds and gateway runtime." return fi done - if [[ "${OPENSHELL_GOVERNANCE_ALLOW_BUNDLED_Z3:-0}" == "1" ]]; then - log "Falling back to bundled Z3 for workspace builds." - ROOT_BUILD_ARGS+=(--features bundled-z3) - return - fi - - missing_z3 + log "No reachable Docker socket detected; relying on gateway driver autodetection." } generate_gateway_jwt_bundle() { @@ -172,16 +243,49 @@ generate_gateway_jwt_bundle() { mkdir -p "$JWT_DIR" openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 - printf 'governance-smoke\n' > "$JWT_DIR/kid" + printf '%s\n' "$GATEWAY_ID" > "$JWT_DIR/kid" +} + +start_dedicated_gateway() { + printf 'INFO starting dedicated gateway\n' + log "Starting dedicated gateway id=$GATEWAY_ID endpoint=http://$GATEWAY_ADDR health=http://$HEALTH_ADDR" + env \ + -u OPENSHELL_GATEWAY_CONFIG \ + -u OPENSHELL_BIND_ADDRESS \ + -u OPENSHELL_SERVER_PORT \ + -u OPENSHELL_HEALTH_PORT \ + -u OPENSHELL_METRICS_PORT \ + -u OPENSHELL_LOG_LEVEL \ + -u OPENSHELL_TLS_CERT \ + -u OPENSHELL_TLS_KEY \ + -u OPENSHELL_TLS_CLIENT_CA \ + -u OPENSHELL_LOCAL_TLS_DIR \ + -u OPENSHELL_DRIVERS \ + -u OPENSHELL_DISABLE_TLS \ + -u OPENSHELL_OIDC_ISSUER \ + -u OPENSHELL_ENABLE_MTLS_AUTH \ + -u OPENSHELL_OIDC_AUDIENCE \ + -u OPENSHELL_OIDC_JWKS_TTL \ + -u OPENSHELL_OIDC_ROLES_CLAIM \ + -u OPENSHELL_OIDC_ADMIN_ROLE \ + -u OPENSHELL_OIDC_USER_ROLE \ + -u OPENSHELL_OIDC_SCOPES_CLAIM \ + -u OPENSHELL_GRPC_RATE_LIMIT_REQUESTS \ + -u OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS \ + -u OPENSHELL_SERVER_SAN \ + -u OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP \ + "OPENSHELL_DB_URL=sqlite://$TMPDIR/gateway.db" \ + "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! } cd "$ROOT" configure_native_build_env -configure_z3_build_env +configure_container_runtime_env generate_gateway_jwt_bundle -run_step "build gateway" cargo build --quiet -p openshell-server --bin openshell-gateway "${ROOT_BUILD_ARGS[@]}" -run_step "build CLI" cargo build --quiet -p openshell-cli --bin openshell "${ROOT_BUILD_ARGS[@]}" -run_step "build governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell +run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" "$EXAMPLE_DIR/target/debug/governance-interceptor" \ --listen "$INTERCEPTOR_ADDR" \ @@ -205,7 +309,7 @@ allow_unauthenticated_users = true signing_key_path = "$JWT_DIR/signing.pem" public_key_path = "$JWT_DIR/public.pem" kid_path = "$JWT_DIR/kid" -gateway_id = "governance-smoke" +gateway_id = "$GATEWAY_ID" ttl_secs = 0 [[openshell.gateway.interceptors]] @@ -218,19 +322,17 @@ max_response_bytes = 1048576 max_patches = 32 EOF -OPENSHELL_DB_URL="sqlite://$TMPDIR/gateway.db" \ - "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & -GATEWAY_PID=$! +start_dedicated_gateway gateway_ready=0 for _ in {1..60}; do + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "gateway starts with interceptor" + fi if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then gateway_ready=1 break fi - if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then - fail "gateway starts with interceptor" - fi sleep 1 done if [[ "$gateway_ready" == "1" ]]; then @@ -239,7 +341,15 @@ else fail "gateway starts with interceptor" fi -CLI=("$ROOT/target/debug/openshell" --gateway-endpoint "http://$GATEWAY_ADDR") +CLI=( + env + -u OPENSHELL_GATEWAY + -u OPENSHELL_GATEWAY_ENDPOINT + -u OPENSHELL_GATEWAY_INSECURE + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "http://$GATEWAY_ADDR" +) run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy From 11928d954892b39d68e4e8e6209d4d48c8767c31 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 09:02:18 -0700 Subject: [PATCH 06/12] 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 dab9fbd2a..b3215e99d 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -563,7 +563,6 @@ pub struct GatewayInterceptorConfig { pub enum GatewayInterceptorFailurePolicy { FailClosed, FailOpen, - Ignore, } /// Configured override for a manifest binding. @@ -901,9 +900,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 _}; @@ -981,6 +980,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn gateway_interceptor_failure_policy_rejects_ignore() { + let err = + serde_json::from_value::(serde_json::json!("ignore")) + .unwrap_err(); + + assert!(err.to_string().contains("unknown variant `ignore`")); + } + #[test] fn grpc_rate_limit_requires_positive_pair() { assert!(Config::new(None).grpc_rate_limit().is_none()); diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index c8d82566f..004c5a9e3 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -41,7 +41,7 @@ use tonic::codegen::http::Uri; use tonic::transport::{Channel, Endpoint}; use tonic::{Code, Request, Status}; use tower::service_fn; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; pub mod routes; @@ -120,7 +120,6 @@ impl From for Phase { pub enum FailurePolicy { FailClosed, FailOpen, - Ignore, } impl FailurePolicy { @@ -129,7 +128,6 @@ impl FailurePolicy { match self { Self::FailClosed => "fail_closed", Self::FailOpen => "fail_open", - Self::Ignore => "ignore", } } } @@ -139,7 +137,6 @@ impl From for FailurePolicy { match value { GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, - GatewayInterceptorFailurePolicy::Ignore => Self::Ignore, } } } @@ -270,10 +267,11 @@ impl GatewayInterceptorRuntime { )) })? .into_inner(); + let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; let service_default = config .failure_policy .map(FailurePolicy::from) - .or_else(|| parse_failure_policy(manifest.failure_policy.as_str()).ok()) + .or(manifest_default) .unwrap_or(FailurePolicy::FailClosed); let max_response_bytes = config .max_response_bytes @@ -302,14 +300,6 @@ impl GatewayInterceptorRuntime { ))); } for phase in normalized.phases { - if normalized.failure_policy == FailurePolicy::Ignore - && phase != Phase::PostCommit - { - return Err(InterceptorError::Config(format!( - "interceptor '{}' binding '{}' uses failure_policy=ignore outside post_commit", - config.name, normalized.binding_id - ))); - } let plan = BindingPlan { interceptor_name: config.name.clone(), binding_id: normalized.binding_id.clone(), @@ -571,7 +561,7 @@ fn normalize_binding( } let mut failure_policy = - parse_failure_policy(binding.failure_policy.as_str()).unwrap_or(service_default); + parse_optional_failure_policy(&binding.failure_policy)?.unwrap_or(service_default); if let Some(override_cfg) = overrides.get(binding_id, &selector) { if let Some(override_selector) = override_selector(override_cfg)? @@ -667,14 +657,11 @@ fn parse_rpc_selector(value: &str) -> Result { Ok(RpcSelector::new(service, method)) } -fn parse_failure_policy(value: &str) -> Result { +fn parse_optional_failure_policy(value: &str) -> Result> { match value.trim() { - "" => Err(InterceptorError::Config( - "failure_policy must not be empty".to_string(), - )), - "fail_closed" => Ok(FailurePolicy::FailClosed), - "fail_open" => Ok(FailurePolicy::FailOpen), - "ignore" => Ok(FailurePolicy::Ignore), + "" => Ok(None), + "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), + "fail_open" => Ok(Some(FailurePolicy::FailOpen)), other => Err(InterceptorError::Config(format!( "unsupported failure_policy '{other}'" ))), @@ -813,16 +800,6 @@ fn apply_failure_policy( counter!("openshell_gateway_interceptor_fail_open_total").increment(1); Ok(()) } - FailurePolicy::Ignore => { - debug!( - interceptor = %plan.interceptor_name, - binding_id = %plan.binding_id, - phase = plan.phase.as_str(), - error = %err, - "gateway interceptor failure ignored" - ); - Ok(()) - } } } @@ -1956,6 +1933,40 @@ mod tests { assert!(parse_duration("2").is_err()); } + #[test] + fn service_default_failure_policy_rejects_ignore() { + let err = parse_optional_failure_policy("ignore").unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + + #[test] + fn binding_failure_policy_rejects_ignore() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "binding".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::Validate as i32], + failure_policy: "ignore".to_string(), + }; + + let err = normalize_binding("test", &binding, FailurePolicy::FailClosed, &override_index) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + #[test] fn dynamic_create_sandbox_round_trip_uses_json_names() { let descriptors = diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 351ffb876..f86faf1f8 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -160,7 +160,7 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target unary OpenShell methods that are not on the built-in supervisor, streaming, read-only, or introspection allowlist. Request bodies are exposed as protobuf JSON objects, so adding a new unary RPC does not require handler-specific interceptor code. -`failure_policy` accepts `fail_closed`, `fail_open`, or `ignore`. Use `ignore` only for `post_commit` bindings. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. +`failure_policy` accepts `fail_closed` or `fail_open`. `timeout` accepts `ms` and `s` suffixes. Binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index efa8bc88a..28e6785b3 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -67,6 +67,7 @@ message InterceptorManifest { // Bindings declared by the interceptor service. repeated InterceptorBinding bindings = 2; // Optional default failure policy for bindings without their own policy. + // Supported values are "fail_closed" and "fail_open". string failure_policy = 3; } @@ -78,6 +79,7 @@ message InterceptorBinding { // Phases this binding wants to evaluate. repeated GatewayInterceptorPhase phases = 3; // Optional binding-specific failure policy. + // Supported values are "fail_closed" and "fail_open". string failure_policy = 4; } From 4da9f726f72856fb0f64cd71bb42c88dfa70fded Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 11:07:57 -0700 Subject: [PATCH 07/12] 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 cc7f6e39e..996ae1565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3768,6 +3768,7 @@ dependencies = [ "tonic", "tower 0.5.3", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index f38b055bb..7800d6628 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -26,5 +26,8 @@ tonic = { workspace = true, features = ["channel", "tls-native-roots"] } tower = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tracing-subscriber = { workspace = true } + [lints] workspace = true diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 004c5a9e3..73eb4355a 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -439,6 +439,7 @@ impl GatewayInterceptorRuntime { result.reason.clone() }; emit_evaluation_metrics(plan, "deny", 0); + emit_evaluation_log(plan, &result, "deny", 0); return Err(status_from_result(&result, reason)); } @@ -448,6 +449,7 @@ impl GatewayInterceptorRuntime { Ok(patched) => { operation = patched; emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, &result, "allow", patch_count); } Err(err) => { apply_failure_policy(plan, &err)?; @@ -455,6 +457,7 @@ impl GatewayInterceptorRuntime { } } else { emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, &result, "allow", 0); } } Ok(operation) @@ -897,6 +900,25 @@ fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) } } +fn emit_evaluation_log( + plan: &BindingPlan, + result: &InterceptorResult, + decision: &str, + patch_count: usize, +) { + info!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + service = %plan.selector.service, + method = %plan.selector.method, + decision, + patch_count, + log_annotations = ?result.log_annotations, + "gateway interceptor evaluated" + ); +} + #[derive(Debug, Clone)] struct GrpcFrame { compressed: bool, @@ -1925,6 +1947,22 @@ mod tests { use super::*; use openshell_core::proto::{CreateSandboxRequest, SandboxSpec, SandboxTemplate}; use serde_json::json; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::layer::SubscriberExt; + + #[derive(Clone)] + struct TraceBuf(Arc>>); + + impl std::io::Write for TraceBuf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } #[test] fn parses_timeout_suffixes() { @@ -1967,6 +2005,56 @@ mod tests { ); } + #[tokio::test] + async fn evaluation_log_emits_structured_log_annotations() { + let log_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let writer = TraceBuf(log_buf.clone()); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .without_time(); + let subscriber = tracing_subscriber::registry().with(fmt_layer); + let dispatch = tracing::Dispatch::new(subscriber); + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailClosed, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let result = InterceptorResult { + allowed: true, + log_annotations: HashMap::from([ + ( + "correlation_id".to_string(), + "governance:create-sandbox:demo".to_string(), + ), + ("policy_hash".to_string(), "abc123".to_string()), + ]), + ..InterceptorResult::default() + }; + + tracing::dispatcher::with_default(&dispatch, || { + emit_evaluation_log(&plan, &result, "allow", 2); + }); + + let output = String::from_utf8(log_buf.lock().unwrap().clone()).unwrap(); + assert!(output.contains("gateway interceptor evaluated")); + assert!(output.contains("log_annotations")); + assert!(output.contains("correlation_id")); + assert!(output.contains("governance:create-sandbox:demo")); + assert!(output.contains("policy_hash")); + } + #[test] fn dynamic_create_sandbox_round_trip_uses_json_names() { let descriptors = diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index c4ac7f1d3..62fdfca6b 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -8,6 +8,8 @@ extend OpenShell to provide advanced governance over sandbox policies. - every new sandbox is attached to exactly `github` and `gitlab` - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation that is used to verify the policy +- every sandbox creation evaluation adds a `correlation_id` log annotation so the + gateway log can be correlated with interceptor-side decisions - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation - users cannot create provider records other than `github` and `gitlab` @@ -32,6 +34,13 @@ The signing key is generated in memory on each interceptor start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. +Interceptors can also attach non-secret operational metadata to +`InterceptorResult.log_annotations`. The gateway logs that map as structured +interceptor metadata for each successful evaluation. This example adds +`correlation_id = "governance:create-sandbox:"` during +`CreateSandbox` modification alongside the policy hash and signing key ID. Do +not put secrets, tokens, or policy signatures in log annotations. + Gateway TOML snippet: ```toml diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 3ce6b6cc2..b16342fbc 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -164,6 +164,17 @@ expect_output_contains() { fi } +expect_log_contains() { + local label="$1" + local needle="$2" + local path="$3" + if grep -q "$needle" "$path"; then + pass "$label" + else + fail "$label" + fi +} + configure_native_build_env() { if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" @@ -300,7 +311,7 @@ version = 1 bind_address = "$GATEWAY_ADDR" health_bind_address = "$HEALTH_ADDR" disable_tls = true -log_level = "warn" +log_level = "info" [openshell.gateway.auth] allow_unauthenticated_users = true @@ -357,6 +368,8 @@ run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitl expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true +expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" +expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index c2a862ebe..6e1e016e5 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -32,6 +32,7 @@ const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature const POLICY_JWT_ISSUER: &str = "openshell-governance-interceptor"; const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; const POLICY_JWT_SUBJECT: &str = "policy.yaml"; +const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; const SERVICE: &str = "openshell.v1.OpenShell"; const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; @@ -263,10 +264,14 @@ impl GovernanceInterceptorService { let mut result = allow(); result.patches = patches; + result.log_annotations.insert( + "correlation_id".to_string(), + create_sandbox_correlation_id(operation), + ); result - .audit_annotations + .log_annotations .insert("policy_hash".to_string(), self.policy_hash.clone()); - result.audit_annotations.insert( + result.log_annotations.insert( "policy_signature_kid".to_string(), self.policy_signer.kid().to_string(), ); @@ -336,13 +341,23 @@ fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> Interc } } +fn create_sandbox_correlation_id(operation: &Value) -> String { + let sandbox_name = operation + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unnamed"); + format!("{CREATE_SANDBOX_CORRELATION_PREFIX}:{sandbox_name}") +} + fn allow() -> InterceptorResult { InterceptorResult { allowed: true, reason: String::new(), status_code: String::new(), patches: Vec::new(), - audit_annotations: HashMap::new(), + log_annotations: HashMap::new(), } } @@ -352,7 +367,7 @@ fn deny(reason: &str) -> InterceptorResult { reason: reason.to_string(), status_code: "PERMISSION_DENIED".to_string(), patches: Vec::new(), - audit_annotations: HashMap::new(), + log_annotations: HashMap::new(), } } @@ -442,7 +457,10 @@ fn add_policy_signature_patches( policy_signature: &str, ) -> Result<(), Status> { let signature = Value::String(policy_signature.to_string()); - if operation.get("annotations").is_none_or(|value| !value.is_object()) { + if operation + .get("annotations") + .is_none_or(|value| !value.is_object()) + { patches.push(json_patch( "add", "/annotations", @@ -913,7 +931,7 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::ModifyOperation, - json!({"spec": {}, "labels": {"team": "platform"}}), + json!({"name": "demo", "spec": {}, "labels": {"team": "platform"}}), )) .unwrap(); assert!(result.allowed); @@ -930,13 +948,16 @@ mod tests { ); let token = signature_patch_token(&result); assert_eq!(token.split('.').count(), 3); - assert!(result.audit_annotations.contains_key("policy_hash")); - assert!( + assert_eq!( result - .audit_annotations - .contains_key("policy_signature_kid") + .log_annotations + .get("correlation_id") + .map(String::as_str), + Some("governance:create-sandbox:demo") ); - assert!(!result.audit_annotations.contains_key("policy_signature")); + assert!(result.log_annotations.contains_key("policy_hash")); + assert!(result.log_annotations.contains_key("policy_signature_kid")); + assert!(!result.log_annotations.contains_key("policy_signature")); } #[test] diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 28e6785b3..26f3a411c 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -57,8 +57,8 @@ message InterceptorResult { string status_code = 3; // RFC 6902 JSON patches. Only valid during modify_operation. repeated JsonPatch patches = 4; - // Non-secret annotations included in gateway audit logs. - map audit_annotations = 5; + // Non-secret annotations included in gateway logs. + map log_annotations = 5; } message InterceptorManifest { From 6193eb3654ea4d55e30c60d0386000884a5fd4b2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 26 Jun 2026 17:04:50 -0700 Subject: [PATCH 08/12] feat(examples): govern provider profiles in interceptor Signed-off-by: Drew Newberry --- examples/governance-interceptor/Cargo.lock | 21 + examples/governance-interceptor/Cargo.toml | 2 + examples/governance-interceptor/README.md | 35 +- examples/governance-interceptor/policy.yaml | 24 +- .../profiles/github.yaml | 33 + .../profiles/slack.yaml | 36 + examples/governance-interceptor/smoke.sh | 425 ++++++----- examples/governance-interceptor/src/main.rs | 676 ++++++++++++++++-- 8 files changed, 932 insertions(+), 320 deletions(-) create mode 100644 examples/governance-interceptor/profiles/github.yaml create mode 100644 examples/governance-interceptor/profiles/slack.yaml diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 6ca15e7f6..137db87eb 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -382,6 +382,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.15" @@ -883,10 +889,12 @@ dependencies = [ "jsonwebtoken", "openshell-core", "openshell-policy", + "openshell-providers", "prost-types", "rcgen", "serde", "serde_json", + "serde_yml", "sha2", "tokio", "tonic", @@ -903,6 +911,19 @@ dependencies = [ "serde_yml", ] +[[package]] +name = "openshell-providers" +version = "0.0.0" +dependencies = [ + "glob", + "openshell-core", + "serde", + "serde_json", + "serde_yml", + "thiserror", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml index a8ff9106c..aae6f8c69 100644 --- a/examples/governance-interceptor/Cargo.toml +++ b/examples/governance-interceptor/Cargo.toml @@ -15,10 +15,12 @@ base64 = "0.22" jsonwebtoken = "9" openshell-core = { path = "../../crates/openshell-core", default-features = false } openshell-policy = { path = "../../crates/openshell-policy" } +openshell-providers = { path = "../../crates/openshell-providers" } prost-types = "0.14" rcgen = { version = "0.13", features = ["crypto", "pem"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" sha2 = "0.10" tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } tonic = { version = "0.14", features = ["transport"] } diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index 62fdfca6b..ed2291702 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -5,22 +5,31 @@ This standalone example implements the extend OpenShell to provide advanced governance over sandbox policies. - every new sandbox receives `policy.yaml` sourced from this examples folder -- every new sandbox is attached to exactly `github` and `gitlab` +- every new sandbox is attached to exactly `github` and `slack` +- `github` must use the `github` provider profile +- `slack` must use the custom `slack` provider profile +- governed provider network policy lives in `profiles/*.yaml`, not in the + signed baseline sandbox policy - every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata annotation that is used to verify the policy - every sandbox creation evaluation adds a `correlation_id` log annotation so the gateway log can be correlated with interceptor-side decisions - users cannot attach or detach other providers after sandbox creation - users cannot replace or merge sandbox policy after sandbox creation -- users cannot create provider records other than `github` and `gitlab` -- users cannot update or delete the governed `github` or `gitlab` provider records +- users cannot create provider records other than `github` and `slack` +- users cannot update or delete the governed `github` or `slack` provider records +- users cannot import or update provider profiles other than `github` and + `slack` +- provider profile deletion is blocked by the interceptor Run the interceptor: ```shell cargo run -- \ --listen 127.0.0.1:18081 \ - --policy policy.yaml + --policy policy.yaml \ + --profiles profiles \ + --gateway-endpoint http://127.0.0.1:8080 ``` At startup the example parses `policy.yaml`, converts it to the protobuf JSON @@ -30,6 +39,22 @@ sandbox under `metadata.annotations["openshell.nvidia.com/policy-signature"]` an verifies the JWT against the sandbox policy during the `CreateSandbox` validate phase. +Provider profile YAML files are loaded by the interceptor from `--profiles` +(default: this example's `profiles/` directory). The interceptor names each +profile from its filename without the extension: `profiles/github.yaml` becomes +profile ID `github`, and `profiles/slack.yaml` becomes profile ID `slack`. The +YAML files do not need an `id` field; if one is present, the filename still wins. + +When `--gateway-endpoint` is set, the interceptor reconciles the loaded profiles +through the gateway's normal provider profile APIs. GitHub is already a built-in +read-only profile, so the interceptor accepts the exported built-in `github` +profile as present; the gateway still rejects importing or updating that +built-in ID. Slack is a custom profile: the interceptor uses +`ImportProviderProfiles` for first-time vending and `UpdateProviderProfiles` for +ongoing changes. It exports the current profile to read `resource_version`, +injects that version into the loaded YAML payload, and submits +`UpdateProviderProfiles`. It never deletes governed profiles. + The signing key is generated in memory on each interceptor start. This keeps the example self-contained. Production governance services should load managed signing keys, publish verifier keys, and define a rotation process. @@ -45,7 +70,7 @@ Gateway TOML snippet: ```toml [[openshell.gateway.interceptors]] -name = "source-control-governance" +name = "provider-governance" grpc_endpoint = "http://127.0.0.1:18081" order = 10 failure_policy = "fail_closed" diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml index 5c668a442..407baef22 100644 --- a/examples/governance-interceptor/policy.yaml +++ b/examples/governance-interceptor/policy.yaml @@ -15,26 +15,4 @@ process: run_as_user: sandbox run_as_group: sandbox -network_policies: - github: - name: github-api-readonly - endpoints: - - host: api.github.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - binaries: - - { path: /usr/bin/git } - - { path: /usr/bin/curl } - gitlab: - name: gitlab-api-readonly - endpoints: - - host: gitlab.com - port: 443 - protocol: rest - enforcement: enforce - access: read-only - binaries: - - { path: /usr/bin/git } - - { path: /usr/bin/curl } +network_policies: {} diff --git a/examples/governance-interceptor/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml new file mode 100644 index 000000000..503967a53 --- /dev/null +++ b/examples/governance-interceptor/profiles/github.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: GitHub +description: GitHub API and Git operations +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/examples/governance-interceptor/profiles/slack.yaml b/examples/governance-interceptor/profiles/slack.yaml new file mode 100644 index 000000000..129691954 --- /dev/null +++ b/examples/governance-interceptor/profiles/slack.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: Slack +description: Read-only Slack Web API access for governed sandbox agents +category: messaging +credentials: + - name: api_token + description: Slack bot or user token + env_vars: [SLACK_BOT_TOKEN, SLACK_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /api/team.info + - allow: + method: GET + path: /api/users.info + - allow: + method: GET + path: /api/conversations.info + - allow: + method: GET + path: /api/conversations.history +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index b16342fbc..4cde6038d 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -7,66 +7,106 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" EXAMPLE_DIR="$ROOT/examples/governance-interceptor" TMPDIR="$(mktemp -d)" -SMOKE_RUN_ID="${OPENSHELL_GOVERNANCE_RUN_ID:-governance-smoke-$$-$RANDOM}" +LOG_DIR="$TMPDIR/logs" +JWT_DIR="$TMPDIR/jwt" +GATEWAY_CONFIG="$TMPDIR/gateway.toml" +SMOKE_LOG="$LOG_DIR/smoke.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" +RUN_ID="governance-smoke-$$-$RANDOM" +SANDBOX_NAME="$RUN_ID-sandbox" + +mkdir -p "$LOG_DIR" + +cleanup() { + local status=$? + trap - EXIT + + if [[ -n "${INTERCEPTOR_PID:-}" ]]; then + kill "$INTERCEPTOR_PID" 2>/dev/null || true + wait "$INTERCEPTOR_PID" 2>/dev/null || true + fi + + if [[ -n "${GATEWAY_PID:-}" ]]; then + kill "$GATEWAY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || true + fi + + if [[ "$status" -eq 0 ]]; then + rm -rf "$TMPDIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + + exit "$status" +} +trap cleanup EXIT + port_is_free() { local port="$1" + if command -v lsof >/dev/null 2>&1; then ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 - else + return + fi + + if command -v nc >/dev/null 2>&1; then ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + return fi + + return 0 } choose_port_block() { local count="$1" local start offset ok + for _ in {1..200}; do start=$((20000 + RANDOM % 20000)) ok=1 + for ((offset = 0; offset < count; offset++)); do if ! port_is_free "$((start + offset))"; then ok=0 break fi done + if [[ "$ok" == "1" ]]; then printf '%s\n' "$start" return fi done + echo "failed to find free local ports for smoke test" >&2 exit 1 } -DEFAULT_PORT_BASE="$(choose_port_block 3)" -JWT_DIR="$TMPDIR/jwt" -LOG_DIR="${OPENSHELL_GOVERNANCE_LOG_DIR:-$TMPDIR/logs}" -SMOKE_LOG="$LOG_DIR/smoke.log" -INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" -GATEWAY_LOG="$LOG_DIR/gateway.log" -INTERCEPTOR_ADDR="${OPENSHELL_GOVERNANCE_INTERCEPTOR_ADDR:-127.0.0.1:$DEFAULT_PORT_BASE}" -GATEWAY_ADDR="${OPENSHELL_GOVERNANCE_GATEWAY_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 1))}" -HEALTH_ADDR="${OPENSHELL_GOVERNANCE_HEALTH_ADDR:-127.0.0.1:$((DEFAULT_PORT_BASE + 2))}" -GATEWAY_ID="${OPENSHELL_GOVERNANCE_GATEWAY_ID:-$SMOKE_RUN_ID}" -SANDBOX_NAME="${OPENSHELL_GOVERNANCE_SANDBOX_NAME:-$SMOKE_RUN_ID-sandbox}" -mkdir -p "$LOG_DIR" +PORT_BASE="$(choose_port_block 3)" +INTERCEPTOR_ADDR="127.0.0.1:$PORT_BASE" +GATEWAY_PORT="$((PORT_BASE + 1))" +HEALTH_PORT="$((PORT_BASE + 2))" +GATEWAY_ADDR="127.0.0.1:$GATEWAY_PORT" +HEALTH_ADDR="127.0.0.1:$HEALTH_PORT" +GATEWAY_ENDPOINT="http://$GATEWAY_ADDR" -cleanup() { - status=$? - trap - EXIT - if [[ -n "${INTERCEPTOR_PID:-}" ]]; then kill "$INTERCEPTOR_PID" 2>/dev/null || true; fi - if [[ -n "${GATEWAY_PID:-}" ]]; then kill "$GATEWAY_PID" 2>/dev/null || true; fi - if [[ "$status" -eq 0 && "${OPENSHELL_GOVERNANCE_KEEP_LOGS:-0}" != "1" ]]; then - rm -rf "$TMPDIR" +dump_log_file() { + local label="$1" + local path="$2" + + printf '\n--- %s: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 else - echo "logs retained in $LOG_DIR" >&2 + printf '(missing)\n' >&2 fi - exit "$status" } -trap cleanup EXIT -log() { - printf '%s\n' "$*" >>"$SMOKE_LOG" +dump_logs() { + dump_log_file "smoke log" "$SMOKE_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" } pass() { @@ -79,51 +119,34 @@ fail() { exit 1 } -setup_fail() { - printf 'ERROR %s\n' "$1" >&2 - dump_logs - exit 1 -} - -dump_log_file() { +log_command() { local label="$1" - local path="$2" - printf '\n--- %s: %s ---\n' "$label" "$path" >&2 - if [[ -f "$path" ]]; then - cat "$path" >&2 - else - printf '(missing)\n' >&2 - fi -} + shift -dump_logs() { - dump_log_file "smoke log" "$SMOKE_LOG" - dump_log_file "gateway log" "$GATEWAY_LOG" - dump_log_file "interceptor log" "$INTERCEPTOR_LOG" + { + printf '\n== %s ==\n' "$label" + printf '+' + printf ' %q' "$@" + printf '\n' + } >>"$SMOKE_LOG" } run_setup_step() { local label="$1" shift + printf 'INFO %s\n' "$label" - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + log_command "$label" "$@" if ! "$@" >>"$SMOKE_LOG" 2>&1; then - setup_fail "$label" + fail "$label" fi } run_step() { local label="$1" shift - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + + log_command "$label" "$@" if "$@" >>"$SMOKE_LOG" 2>&1; then pass "$label" else @@ -134,11 +157,8 @@ run_step() { expect_failure() { local label="$1" shift - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" + + log_command "$label" "$@" if "$@" >>"$SMOKE_LOG" 2>&1; then fail "$label" else @@ -151,12 +171,9 @@ expect_output_contains() { local needle="$2" shift 2 local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" - { - printf '\n== %s ==\n' "$label" - printf '+ %q ' "$@" - printf '\n' - } >>"$SMOKE_LOG" - if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -q "$needle" "$output_file"; then + + log_command "$label" "$@" + if "$@" >"$output_file" 2>>"$SMOKE_LOG" && grep -Fq -- "$needle" "$output_file"; then pass "$label" else cat "$output_file" >>"$SMOKE_LOG" 2>/dev/null || true @@ -168,81 +185,32 @@ expect_log_contains() { local label="$1" local needle="$2" local path="$3" - if grep -q "$needle" "$path"; then + + if grep -Fq -- "$needle" "$path"; then pass "$label" else fail "$label" fi } -configure_native_build_env() { - if [[ "$(uname -s)" == "Darwin" && "${OPENSHELL_GOVERNANCE_KEEP_CC:-0}" != "1" ]]; then - export CC="${OPENSHELL_GOVERNANCE_CC:-clang}" - export CXX="${OPENSHELL_GOVERNANCE_CXX:-clang++}" - log "Using macOS native build compiler: CC=$CC CXX=$CXX" - fi - - if [[ "${OPENSHELL_GOVERNANCE_KEEP_RUSTC_WRAPPER:-0}" != "1" ]]; then - export RUSTC_WRAPPER="${OPENSHELL_GOVERNANCE_RUSTC_WRAPPER:-}" - fi - - if [[ -z "${RUSTC_WRAPPER:-}" ]]; then - log "Building without RUSTC_WRAPPER for reproducible smoke builds." - else - log "Using RUSTC_WRAPPER=$RUSTC_WRAPPER" - fi -} - -docker_socket_responds() { - local socket="$1" - curl --silent --fail --unix-socket "$socket" http://localhost/_ping >/dev/null 2>&1 -} - -docker_context_socket() { - if ! command -v docker >/dev/null 2>&1; then - return - fi - - local endpoint - endpoint="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)" - if [[ "$endpoint" == unix://* ]]; then - printf '%s\n' "${endpoint#unix://}" - fi -} - -configure_container_runtime_env() { - if [[ -n "${DOCKER_HOST:-}" ]]; then - log "Using caller-provided DOCKER_HOST=$DOCKER_HOST" - return - fi - - local candidate - local -a candidates=() +wait_for_profile() { + local profile_id="$1" + local label="loads $profile_id provider profile" - candidate="$(docker_context_socket)" - if [[ -n "$candidate" ]]; then - candidates+=("$candidate") - fi - - if [[ -n "${HOME:-}" ]]; then - candidates+=( - "$HOME/.colima/default/docker.sock" - "$HOME/.colima/docker.sock" - "$HOME/.docker/run/docker.sock" - ) - fi - - candidates+=("/var/run/docker.sock") + { + printf '\n== %s ==\n' "$label" + printf '+ wait for provider profile %q\n' "$profile_id" + } >>"$SMOKE_LOG" - for candidate in "${candidates[@]}"; do - if [[ -S "$candidate" ]] && docker_socket_responds "$candidate"; then - export DOCKER_HOST="unix://$candidate" - log "Using Docker socket from $DOCKER_HOST for workspace builds and gateway runtime." + for _ in {1..60}; do + if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SMOKE_LOG" 2>&1; then + pass "$label" return fi + sleep 1 done - log "No reachable Docker socket detected; relying on gateway driver autodetection." + fail "$label" } generate_gateway_jwt_bundle() { @@ -254,65 +222,14 @@ generate_gateway_jwt_bundle() { mkdir -p "$JWT_DIR" openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 - printf '%s\n' "$GATEWAY_ID" > "$JWT_DIR/kid" -} - -start_dedicated_gateway() { - printf 'INFO starting dedicated gateway\n' - log "Starting dedicated gateway id=$GATEWAY_ID endpoint=http://$GATEWAY_ADDR health=http://$HEALTH_ADDR" - env \ - -u OPENSHELL_GATEWAY_CONFIG \ - -u OPENSHELL_BIND_ADDRESS \ - -u OPENSHELL_SERVER_PORT \ - -u OPENSHELL_HEALTH_PORT \ - -u OPENSHELL_METRICS_PORT \ - -u OPENSHELL_LOG_LEVEL \ - -u OPENSHELL_TLS_CERT \ - -u OPENSHELL_TLS_KEY \ - -u OPENSHELL_TLS_CLIENT_CA \ - -u OPENSHELL_LOCAL_TLS_DIR \ - -u OPENSHELL_DRIVERS \ - -u OPENSHELL_DISABLE_TLS \ - -u OPENSHELL_OIDC_ISSUER \ - -u OPENSHELL_ENABLE_MTLS_AUTH \ - -u OPENSHELL_OIDC_AUDIENCE \ - -u OPENSHELL_OIDC_JWKS_TTL \ - -u OPENSHELL_OIDC_ROLES_CLAIM \ - -u OPENSHELL_OIDC_ADMIN_ROLE \ - -u OPENSHELL_OIDC_USER_ROLE \ - -u OPENSHELL_OIDC_SCOPES_CLAIM \ - -u OPENSHELL_GRPC_RATE_LIMIT_REQUESTS \ - -u OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS \ - -u OPENSHELL_SERVER_SAN \ - -u OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP \ - "OPENSHELL_DB_URL=sqlite://$TMPDIR/gateway.db" \ - "$ROOT/target/debug/openshell-gateway" --config "$TMPDIR/gateway.toml" >"$GATEWAY_LOG" 2>&1 & - GATEWAY_PID=$! + printf '%s\n' "$RUN_ID" >"$JWT_DIR/kid" } -cd "$ROOT" -configure_native_build_env -configure_container_runtime_env -generate_gateway_jwt_bundle -run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway -run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell -run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" - -"$EXAMPLE_DIR/target/debug/governance-interceptor" \ - --listen "$INTERCEPTOR_ADDR" \ - --policy "$EXAMPLE_DIR/policy.yaml" >"$INTERCEPTOR_LOG" 2>&1 & -INTERCEPTOR_PID=$! - -cat > "$TMPDIR/gateway.toml" <"$GATEWAY_CONFIG" <"$INTERCEPTOR_LOG" 2>&1 & + INTERCEPTOR_PID=$! +} -gateway_ready=0 -for _ in {1..60}; do - if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then - fail "gateway starts with interceptor" - fi - if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then - gateway_ready=1 - break - fi - sleep 1 -done -if [[ "$gateway_ready" == "1" ]]; then - pass "gateway starts with interceptor" -else - fail "gateway starts with interceptor" -fi +start_gateway() { + printf 'INFO starting gateway\n' + env -u OPENSHELL_DRIVERS "$ROOT/target/debug/openshell-gateway" \ + --config "$GATEWAY_CONFIG" \ + --bind-address 127.0.0.1 \ + --port "$GATEWAY_PORT" \ + --health-port "$HEALTH_PORT" \ + --metrics-port 0 \ + --log-level info \ + --disable-tls \ + --db-url "sqlite://$TMPDIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! +} + +wait_for_gateway() { + local label="gateway starts with interceptor" + + for _ in {1..60}; do + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "$label" + fi + + if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then + pass "$label" + return + fi + + sleep 1 + done + + fail "$label" +} + +run_suite() { + CLI=( + env + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "$GATEWAY_ENDPOINT" + ) + + run_step "enables provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes + wait_for_profile "github" + wait_for_profile "slack" + expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles + expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + + cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' +id: custom-slack +display_name: Custom Slack +description: Profile outside the managed github/slack set used to verify interceptor import denial +category: messaging +credentials: [] +endpoints: [] +binaries: [] +EOF -CLI=( - env - -u OPENSHELL_GATEWAY - -u OPENSHELL_GATEWAY_ENDPOINT - -u OPENSHELL_GATEWAY_INSECURE - -u OPENSHELL_SANDBOX_POLICY - "$ROOT/target/debug/openshell" - --gateway-endpoint "http://$GATEWAY_ADDR" -) + expect_failure "denies provider profile delete" "${CLI[@]}" provider profile delete slack + expect_failure "denies disallowed provider profile import" "${CLI[@]}" provider profile import -f "$TMPDIR/disallowed-profile.yaml" -run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy -run_step "allows gitlab provider create" "${CLI[@]}" provider create --name gitlab --type gitlab --credential GITLAB_TOKEN=dummy + expect_failure "denies slack provider with github profile" "${CLI[@]}" provider create --name slack --type github --credential SLACK_BOT_TOKEN=dummy + run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy + run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy -expect_failure "denies non-governed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy + expect_failure "denies disallowed provider create" "${CLI[@]}" provider create --name bitbucket --type github --credential GITHUB_TOKEN=dummy -run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true -expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" -expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" -expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" -expect_output_contains "sandbox has gitlab provider" "gitlab" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + run_step "creates governed sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --no-auto-providers --keep --no-tty -- /bin/sh -lc true + expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" + expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" + expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_contains "sandbox has slack provider" "slack" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_contains "effective policy has github provider layer" "_provider_github" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + expect_output_contains "effective policy has slack provider layer" "_provider_slack" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json -expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + expect_failure "denies provider attach" "${CLI[@]}" sandbox provider attach "$SANDBOX_NAME" github + expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" -expect_failure "denies provider detach" "${CLI[@]}" sandbox provider detach "$SANDBOX_NAME" github + run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" -expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + expect_failure "denies governed provider update" "${CLI[@]}" provider update slack --credential SLACK_BOT_TOKEN=changed + expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github +} -run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" +cd "$ROOT" -expect_failure "denies governed provider update" "${CLI[@]}" provider update gitlab --credential GITLAB_TOKEN=changed +run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building test CLI" cargo build --quiet -p openshell-cli --bin openshell -expect_failure "denies governed provider delete" "${CLI[@]}" provider delete github +generate_gateway_jwt_bundle +write_gateway_config +start_interceptor +start_gateway +wait_for_gateway +run_suite echo "ALL PASS governance interceptor smoke" diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 6e1e016e5..8120a883f 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use std::net::SocketAddr; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ @@ -16,16 +16,20 @@ use openshell_core::proto::gateway_interceptor::v1::{ gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, }; use openshell_core::proto::{ - GraphqlOperation, L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, - SandboxPolicy, + GetProviderProfileRequest, GraphqlOperation, ImportProviderProfilesRequest, L7Allow, + L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, SandboxPolicy, + UpdateProviderProfilesRequest, open_shell_client::OpenShellClient, }; use openshell_policy::parse_sandbox_policy; +use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; use rcgen::{KeyPair, PKCS_ED25519}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value, json}; use sha2::{Digest, Sha256}; -use tonic::transport::Server; +use tonic::Code; +use tonic::transport::{Channel, Server}; use tonic::{Request, Response, Status}; const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature"; @@ -34,7 +38,8 @@ const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; const POLICY_JWT_SUBJECT: &str = "policy.yaml"; const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; const SERVICE: &str = "openshell.v1.OpenShell"; -const GOVERNED_PROVIDERS: [&str; 2] = ["github", "gitlab"]; +const PROFILE_RECONCILE_ATTEMPTS: usize = 60; +const PROFILE_RECONCILE_RETRY_DELAY: Duration = Duration::from_secs(1); #[derive(Clone)] struct PolicySigner { @@ -133,10 +138,20 @@ struct GovernanceInterceptorService { policy_hash: String, policy_signature: String, policy_signer: PolicySigner, + managed_profile_ids: Vec, +} + +#[derive(Clone, Debug)] +struct LoadedProviderProfile { + source: String, + profile: ProviderProfile, } impl GovernanceInterceptorService { - fn from_yaml(policy_yaml: &str) -> Result { + fn from_yaml(policy_yaml: &str, managed_profile_ids: Vec) -> Result { + if managed_profile_ids.is_empty() { + return Err("at least one provider profile must be loaded".to_string()); + } let policy = parse_sandbox_policy(policy_yaml) .map_err(|err| format!("failed to parse policy YAML: {err}"))?; let policy = sandbox_policy_to_proto_json(&policy); @@ -149,12 +164,13 @@ impl GovernanceInterceptorService { policy_hash, policy_signature, policy_signer, + managed_profile_ids, }) } fn manifest() -> InterceptorManifest { InterceptorManifest { - name: "source-control-governance".to_string(), + name: "provider-governance".to_string(), failure_policy: "fail_closed".to_string(), bindings: vec![ binding( @@ -195,6 +211,21 @@ impl GovernanceInterceptorService { "DeleteProvider", &[GatewayInterceptorPhase::Validate], ), + binding( + "govern-import-provider-profiles", + "ImportProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-provider-profiles", + "UpdateProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-delete-provider-profile", + "DeleteProviderProfile", + &[GatewayInterceptorPhase::Validate], + ), ], } } @@ -221,20 +252,27 @@ impl GovernanceInterceptorService { ( "AttachSandboxProvider" | "DetachSandboxProvider", GatewayInterceptorPhase::Validate, - ) => Ok(deny( - "source-control providers are fixed at sandbox creation", - )), + ) => Ok(deny("governed providers are fixed at sandbox creation")), ("UpdateConfig", GatewayInterceptorPhase::Validate) => { Ok(validate_update_config(&operation)) } ("CreateProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_create_provider(&operation)) + Ok(self.validate_create_provider(&operation)) } ("UpdateProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_update_provider(&operation)) + Ok(self.validate_update_provider(&operation)) } ("DeleteProvider", GatewayInterceptorPhase::Validate) => { - Ok(validate_delete_provider(&operation)) + Ok(self.validate_delete_provider(&operation)) + } + ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_import_provider_profiles(&operation)) + } + ("UpdateProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_update_provider_profiles(&operation)) + } + ("DeleteProviderProfile", GatewayInterceptorPhase::Validate) => { + Ok(validate_delete_provider_profile()) } _ => Ok(allow()), } @@ -247,7 +285,7 @@ impl GovernanceInterceptorService { patches.push(json_patch( "add", "/spec/providers", - json!(GOVERNED_PROVIDERS), + json!(&self.managed_profile_ids), )?); } else { patches.push(json_patch( @@ -255,7 +293,7 @@ impl GovernanceInterceptorService { "/spec", json!({ "policy": self.policy, - "providers": GOVERNED_PROVIDERS, + "providers": self.managed_profile_ids, }), )?); } @@ -280,7 +318,7 @@ impl GovernanceInterceptorService { fn validate_create_sandbox(&self, operation: &Value) -> InterceptorResult { let Some(policy) = operation.pointer("/spec/policy") else { - return deny("sandbox policy must match the source-control governance baseline"); + return deny("sandbox policy must match the provider governance baseline"); }; let sandbox_policy_hash = match policy_hash(policy) { Ok(hash) => hash, @@ -302,13 +340,101 @@ impl GovernanceInterceptorService { return deny(&format!("sandbox policy signature is invalid: {err}")); } if sandbox_policy_hash != self.policy_hash || policy != &self.policy { - return deny("sandbox policy must match the source-control governance baseline"); + return deny("sandbox policy must match the provider governance baseline"); + } + if !providers_are_managed( + operation.pointer("/spec/providers"), + &self.managed_profile_ids, + ) { + return deny(&format!( + "sandbox providers must be exactly {}", + format_id_list(&self.managed_profile_ids) + )); + } + allow() + } + + fn validate_create_provider(&self, operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + let provider_type = provider_type(operation); + if !self.is_managed_profile_id(name) { + return deny(&format!( + "only managed provider records may be created: {}", + format_id_list(&self.managed_profile_ids) + )); + } + if provider_type != name { + return deny(&format!("provider '{name}' must use profile '{name}'")); + } + allow() + } + + fn validate_update_provider(&self, operation: &Value) -> InterceptorResult { + let name = provider_name(operation); + if self.is_managed_profile_id(name) { + deny("governed provider records cannot be updated") + } else { + allow() + } + } + + fn validate_delete_provider(&self, operation: &Value) -> InterceptorResult { + let name = operation + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + if self.is_managed_profile_id(name) { + deny("governed provider records cannot be deleted") + } else { + allow() + } + } + + fn validate_import_provider_profiles(&self, operation: &Value) -> InterceptorResult { + let Some(profiles) = operation.get("profiles").and_then(Value::as_array) else { + return deny("provider profile imports must include governed profile payloads"); + }; + if profiles.is_empty() { + return deny("provider profile imports must include governed profile payloads"); + } + for item in profiles { + let id = profile_id_from_import_item(item); + if !self.is_managed_profile_id(id) { + return deny(&format!( + "only managed provider profiles may be imported: {}", + format_id_list(&self.managed_profile_ids) + )); + } + } + allow() + } + + fn validate_update_provider_profiles(&self, operation: &Value) -> InterceptorResult { + let target_id = operation + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + if !self.is_managed_profile_id(target_id) { + return deny(&format!( + "only managed provider profiles may be updated: {}", + format_id_list(&self.managed_profile_ids) + )); } - if !providers_are_governed(operation.pointer("/spec/providers")) { - return deny("sandbox providers must be exactly github and gitlab"); + let payload_id = operation + .get("profile") + .map(profile_id_from_import_item) + .unwrap_or_default(); + if payload_id != target_id { + return deny( + "provider profile update target must match the governed profile payload id", + ); } allow() } + + fn is_managed_profile_id(&self, id: &str) -> bool { + self.managed_profile_ids.iter().any(|managed| managed == id) + } } #[tonic::async_trait] @@ -387,58 +513,284 @@ fn validate_update_config(operation: &Value) -> InterceptorResult { } } -fn validate_create_provider(operation: &Value) -> InterceptorResult { - let name = provider_name(operation); - if is_governed_provider(name) { - allow() - } else { - deny("only github and gitlab provider records may be created") - } +fn validate_delete_provider_profile() -> InterceptorResult { + deny("provider profile deletes are blocked by provider governance") } -fn validate_update_provider(operation: &Value) -> InterceptorResult { - let name = provider_name(operation); - if is_governed_provider(name) { - deny("governed provider records cannot be updated") - } else { - allow() - } +fn provider_name(operation: &Value) -> &str { + operation + .pointer("/provider/metadata/name") + .and_then(Value::as_str) + .unwrap_or_default() } -fn validate_delete_provider(operation: &Value) -> InterceptorResult { - let name = operation - .get("name") +fn provider_type(operation: &Value) -> &str { + operation + .pointer("/provider/type") .and_then(Value::as_str) - .unwrap_or_default(); - if is_governed_provider(name) { - deny("governed provider records cannot be deleted") - } else { - allow() - } + .unwrap_or_default() } -fn provider_name(operation: &Value) -> &str { - operation - .pointer("/provider/metadata/name") +fn profile_id_from_import_item(item: &Value) -> &str { + item.pointer("/profile/id") .and_then(Value::as_str) .unwrap_or_default() } -fn is_governed_provider(name: &str) -> bool { - GOVERNED_PROVIDERS.contains(&name) +fn load_provider_profiles(path: &Path) -> Result, String> { + if path.is_dir() { + let mut entries = std::fs::read_dir(path) + .map_err(|err| format!("failed to read provider profiles dir: {err}"))? + .collect::, _>>() + .map_err(|err| format!("failed to read provider profiles dir entry: {err}"))?; + entries.sort_by_key(|entry| entry.path()); + let mut profiles = Vec::new(); + for entry in entries { + let path = entry.path(); + if !profile_path_supported(&path) { + continue; + } + profiles.push(load_provider_profile_file(&path)?); + } + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + if path.is_file() { + let profiles = vec![load_provider_profile_file(path)?]; + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + Err(format!( + "provider profiles path not found: {}", + path.display() + )) +} + +fn load_provider_profile_file(path: &Path) -> Result { + let profile_id = profile_id_from_file_name(path)?; + let input = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read provider profile {}: {err}", path.display()))?; + let source = path.display().to_string(); + load_provider_profile_source(&source, &input, &profile_id) +} + +fn load_provider_profile_source( + source: &str, + input: &str, + profile_id: &str, +) -> Result { + let mut value = serde_yml::from_str::(input) + .map_err(|err| format!("failed to parse provider profile {source}: {err}"))?; + let mapping = value + .as_mapping_mut() + .ok_or_else(|| format!("provider profile {source} must be a YAML mapping"))?; + mapping.insert( + serde_yml::Value::String("id".to_string()), + serde_yml::Value::String(profile_id.to_string()), + ); + let profile = serde_yml::from_value::(value) + .map_err(|err| format!("failed to decode provider profile {source}: {err}"))? + .to_proto(); + Ok(LoadedProviderProfile { + source: source.to_string(), + profile, + }) +} + +fn profile_id_from_file_name(path: &Path) -> Result { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| { + format!( + "provider profile path has no UTF-8 file stem: {}", + path.display() + ) + })?; + let Some(normalized) = normalize_profile_id(stem) else { + return Err(format!( + "provider profile filename stem must be lowercase kebab-case: {}", + path.display() + )); + }; + if normalized != stem { + return Err(format!( + "provider profile filename stem must already be normalized: {}", + path.display() + )); + } + Ok(normalized) +} + +fn profile_path_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml") + ) +} + +fn validate_loaded_profiles(profiles: &[LoadedProviderProfile]) -> Result<(), String> { + if profiles.is_empty() { + return Err("provider profiles path did not contain any YAML files".to_string()); + } + let mut ids = profiles + .iter() + .map(|profile| profile.profile.id.as_str()) + .collect::>(); + ids.sort_unstable(); + for pair in ids.windows(2) { + if pair[0] == pair[1] { + return Err(format!( + "duplicate provider profile filename stem: {}", + pair[0] + )); + } + } + Ok(()) +} + +fn loaded_profile_ids(profiles: &[LoadedProviderProfile]) -> Vec { + profiles + .iter() + .map(|profile| profile.profile.id.clone()) + .collect() +} + +fn format_id_list(ids: &[String]) -> String { + ids.join(", ") +} + +fn spawn_profile_reconciler(gateway_endpoint: String, profiles: Vec) { + tokio::spawn(async move { + let mut last_error = String::new(); + for attempt in 1..=PROFILE_RECONCILE_ATTEMPTS { + match reconcile_provider_profiles(&gateway_endpoint, &profiles).await { + Ok(()) => { + println!("provider profiles reconciled with gateway {gateway_endpoint}"); + return; + } + Err(err) => { + last_error = err; + eprintln!( + "provider profile reconcile attempt {attempt}/{PROFILE_RECONCILE_ATTEMPTS} failed: {last_error}" + ); + tokio::time::sleep(PROFILE_RECONCILE_RETRY_DELAY).await; + } + } + } + eprintln!( + "provider profile reconcile failed after {PROFILE_RECONCILE_ATTEMPTS} attempts: {last_error}" + ); + }); +} + +async fn reconcile_provider_profiles( + gateway_endpoint: &str, + profiles: &[LoadedProviderProfile], +) -> Result<(), String> { + let mut client = OpenShellClient::connect(gateway_endpoint.to_string()) + .await + .map_err(|err| format!("connect gateway: {err}"))?; + for loaded in profiles { + reconcile_provider_profile(&mut client, loaded).await?; + } + Ok(()) +} + +async fn reconcile_provider_profile( + client: &mut OpenShellClient, + loaded: &LoadedProviderProfile, +) -> Result<(), String> { + let id = loaded.profile.id.clone(); + match client + .get_provider_profile(GetProviderProfileRequest { id: id.clone() }) + .await + { + Ok(response) => { + let current = response + .into_inner() + .profile + .ok_or_else(|| format!("provider profile '{id}' missing from get response"))?; + if current.resource_version == 0 { + println!("provider profile '{id}' is built in; using gateway copy"); + return Ok(()); + } + let mut profile = loaded.profile.clone(); + profile.resource_version = current.resource_version; + let response = client + .update_provider_profiles(UpdateProviderProfilesRequest { + id: id.clone(), + expected_resource_version: profile.resource_version, + profile: Some(ProviderProfileImportItem { + source: loaded.source.clone(), + profile: Some(profile), + }), + }) + .await + .map_err(|status| format!("update provider profile '{id}': {status}"))? + .into_inner(); + if response.updated { + println!("updated provider profile '{id}'"); + Ok(()) + } else { + Err(format!( + "update provider profile '{id}' rejected: {}", + format_profile_diagnostics(&response.diagnostics) + )) + } + } + Err(status) if status.code() == Code::NotFound => { + let response = client + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + source: loaded.source.clone(), + profile: Some(loaded.profile.clone()), + }], + }) + .await + .map_err(|status| format!("import provider profile '{id}': {status}"))? + .into_inner(); + if response.imported { + println!("imported provider profile '{id}'"); + Ok(()) + } else { + Err(format!( + "import provider profile '{id}' rejected: {}", + format_profile_diagnostics(&response.diagnostics) + )) + } + } + Err(status) => Err(format!("get provider profile '{id}': {status}")), + } +} + +fn format_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) -> String { + if diagnostics.is_empty() { + return "no diagnostics returned".to_string(); + } + diagnostics + .iter() + .map(|diagnostic| { + format!( + "{}:{}:{}:{}", + diagnostic.source, diagnostic.profile_id, diagnostic.field, diagnostic.message + ) + }) + .collect::>() + .join("; ") } -fn providers_are_governed(value: Option<&Value>) -> bool { +fn providers_are_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { let Some(Value::Array(providers)) = value else { return false; }; - if providers.len() != GOVERNED_PROVIDERS.len() { + if providers.len() != managed_profile_ids.len() { return false; } - GOVERNED_PROVIDERS.iter().all(|provider| { + managed_profile_ids.iter().all(|provider| { providers .iter() - .any(|value| value.as_str() == Some(provider)) + .any(|value| value.as_str() == Some(provider.as_str())) }) } @@ -804,6 +1156,8 @@ fn proto_value_to_json(value: &ProtoValue) -> Value { async fn main() -> Result<(), Box> { let mut listen: SocketAddr = "127.0.0.1:18081".parse()?; let mut policy_path: Option = None; + let mut profiles_path: Option = None; + let mut gateway_endpoint: Option = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { @@ -815,8 +1169,18 @@ async fn main() -> Result<(), Box> { let value = args.next().ok_or("--policy requires a path")?; policy_path = Some(PathBuf::from(value)); } + "--profiles" => { + let value = args.next().ok_or("--profiles requires a path")?; + profiles_path = Some(PathBuf::from(value)); + } + "--gateway-endpoint" => { + let value = args.next().ok_or("--gateway-endpoint requires a URL")?; + gateway_endpoint = Some(value); + } "-h" | "--help" => { - println!("usage: governance-interceptor [--listen ADDR] [--policy FILE]"); + println!( + "usage: governance-interceptor [--listen ADDR] [--policy FILE] [--profiles FILE_OR_DIR] [--gateway-endpoint URL]" + ); return Ok(()); } _ => return Err(format!("unknown argument: {arg}").into()), @@ -828,7 +1192,23 @@ async fn main() -> Result<(), Box> { } else { include_str!("../policy.yaml").to_string() }; - let service = GovernanceInterceptorService::from_yaml(&policy_yaml)?; + let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); + let profiles = load_provider_profiles(&profiles_path)?; + let profile_ids = loaded_profile_ids(&profiles); + let service = GovernanceInterceptorService::from_yaml(&policy_yaml, profile_ids)?; + + if let Some(endpoint) = gateway_endpoint { + spawn_profile_reconciler(endpoint, profiles); + } else { + println!( + "loaded provider profiles: {}", + profiles + .iter() + .map(|profile| profile.profile.id.as_str()) + .collect::>() + .join(", ") + ); + } println!("governance interceptor listening on {listen}"); Server::builder() @@ -838,12 +1218,21 @@ async fn main() -> Result<(), Box> { Ok(()) } +fn default_profiles_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles") +} + #[cfg(test)] mod tests { use super::*; fn service() -> GovernanceInterceptorService { - GovernanceInterceptorService::from_yaml(include_str!("../policy.yaml")).unwrap() + let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); + GovernanceInterceptorService::from_yaml( + include_str!("../policy.yaml"), + loaded_profile_ids(&profiles), + ) + .unwrap() } fn evaluation( @@ -863,11 +1252,15 @@ mod tests { } } - fn governed_create_operation(policy: Value, signature: String) -> Value { + fn governed_create_operation( + service: &GovernanceInterceptorService, + policy: Value, + signature: String, + ) -> Value { let mut operation = json!({ "spec": { "policy": policy, - "providers": GOVERNED_PROVIDERS, + "providers": &service.managed_profile_ids, }, "annotations": {}, }); @@ -883,7 +1276,11 @@ mod tests { } fn valid_create_operation(service: &GovernanceInterceptorService) -> Value { - governed_create_operation(service.policy.clone(), service.policy_signature.clone()) + governed_create_operation( + service, + service.policy.clone(), + service.policy_signature.clone(), + ) } fn signature_patch_token(result: &InterceptorResult) -> String { @@ -921,9 +1318,44 @@ mod tests { assert!(ids.contains(&"govern-create-sandbox")); assert!(ids.contains(&"govern-attach-provider")); assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-import-provider-profiles")); + assert!(ids.contains(&"govern-update-provider-profiles")); + assert!(ids.contains(&"govern-delete-provider-profile")); assert_eq!(manifest.failure_policy, "fail_closed"); } + #[test] + fn profile_loader_uses_file_name_as_profile_id() { + let loaded = load_provider_profile_source( + "profiles/example-api.yaml", + r#" +id: ignored +display_name: Example API +description: Example profile +credentials: [] +endpoints: [] +binaries: [] +"#, + "example-api", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "example-api"); + + let loaded = load_provider_profile_source( + "profiles/no-id.yaml", + r#" +display_name: No ID +description: Filename supplies the profile id +credentials: [] +endpoints: [] +binaries: [] +"#, + "no-id", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "no-id"); + } + #[test] fn create_sandbox_modify_adds_policy_providers_and_signature() { let service = service(); @@ -983,7 +1415,7 @@ mod tests { json!({ "spec": { "policy": service.policy, - "providers": GOVERNED_PROVIDERS, + "providers": service.managed_profile_ids, }, }), )) @@ -999,7 +1431,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(service.policy.clone(), "not-a-jwt".to_string()), + governed_create_operation( + &service, + service.policy.clone(), + "not-a-jwt".to_string(), + ), )) .unwrap(); assert!(!result.allowed); @@ -1014,7 +1450,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(governance.policy.clone(), other.policy_signature), + governed_create_operation( + &governance, + governance.policy.clone(), + other.policy_signature, + ), )) .unwrap(); assert!(!result.allowed); @@ -1033,7 +1473,11 @@ mod tests { .evaluate_inner(&evaluation( "CreateSandbox", GatewayInterceptorPhase::Validate, - governed_create_operation(tampered_policy, service.policy_signature.clone()), + governed_create_operation( + &service, + tampered_policy, + service.policy_signature.clone(), + ), )) .unwrap(); assert!(!result.allowed); @@ -1056,7 +1500,7 @@ mod tests { .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "github"}}}), + json!({"provider": {"metadata": {"name": "github"}, "type": "github"}}), )) .unwrap(); assert!(github.allowed); @@ -1065,10 +1509,110 @@ mod tests { .evaluate_inner(&evaluation( "CreateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "slack"}}}), + json!({"provider": {"metadata": {"name": "slack"}, "type": "slack"}}), + )) + .unwrap(); + assert!(slack.allowed); + + let wrong_profile = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}, "type": "github"}}), + )) + .unwrap(); + assert!(!wrong_profile.allowed); + assert!(wrong_profile.reason.contains("slack")); + + let teams = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}), + )) + .unwrap(); + assert!(!teams.allowed); + } + + #[test] + fn provider_profile_import_is_limited_to_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "profiles": [ + {"profile": {"id": "github"}}, + {"profile": {"id": "slack"}} + ] + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({"profiles": [{"profile": {"id": "custom-slack"}}]}), + )) + .unwrap(); + assert!(!result.allowed); + } + + #[test] + fn provider_profile_update_is_limited_to_matching_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "slack"}} + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "github"}} + }), + )) + .unwrap(); + assert!(!result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "custom-slack", + "profile": {"profile": {"id": "custom-slack"}} + }), )) .unwrap(); - assert!(!slack.allowed); + assert!(!result.allowed); + } + + #[test] + fn provider_profile_delete_is_denied() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "DeleteProviderProfile", + GatewayInterceptorPhase::Validate, + json!({"id": "github"}), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("deletes are blocked")); } #[test] @@ -1111,7 +1655,7 @@ mod tests { .evaluate_inner(&evaluation( "UpdateProvider", GatewayInterceptorPhase::Validate, - json!({"provider": {"metadata": {"name": "gitlab"}}}), + json!({"provider": {"metadata": {"name": "slack"}}}), )) .unwrap(); assert!(!update.allowed); From 84c9c1288f0fdbe720281cf9385091afecaa0d51 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 29 Jun 2026 11:17:46 -0700 Subject: [PATCH 09/12] 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 ceaf338ae..6023492b1 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 b0ff2617b..8ebf2c04b 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!( " {} {}", @@ -6316,12 +6323,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()? @@ -6514,13 +6517,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()? @@ -6549,13 +6549,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()? @@ -6584,13 +6580,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()? @@ -6619,13 +6612,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()? @@ -6679,12 +6668,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()?; @@ -6853,13 +6837,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 96158a1d1..0259b3525 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -607,12 +607,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 73eb4355a..3b2b9492c 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 53b11891f..780ecd428 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}; @@ -1049,6 +1049,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, @@ -1458,6 +1513,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( @@ -1490,6 +1546,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 { @@ -1535,12 +1596,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); @@ -1590,12 +1652,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. @@ -1638,12 +1701,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() { @@ -1660,6 +1724,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; @@ -1693,12 +1758,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 { @@ -1726,12 +1801,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 { @@ -1755,6 +1840,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( @@ -1790,12 +1883,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. @@ -1835,7 +1929,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, @@ -1846,10 +1941,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" @@ -1868,12 +1969,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); @@ -1890,6 +2001,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!( @@ -1900,12 +2020,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, + )) } // --------------------------------------------------------------------------- @@ -9819,6 +9940,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + annotations: HashMap::new(), }), ) .await @@ -9846,6 +9968,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; @@ -9891,6 +10431,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10029,6 +10570,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + annotations: HashMap::new(), }), ) .await @@ -10115,6 +10657,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 4538b207d..0d59c5858 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 dc5ead7c0..0c80c379d 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -2079,13 +2079,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; @@ -2115,13 +2112,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; @@ -2185,13 +2179,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; @@ -2225,13 +2215,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; @@ -2350,6 +2336,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 7cdbec8bd..f212f21b0 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 5fdb6970c..be4bc1464 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 fb4fcf87af9f7994472aea7fea441bb590044ea5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 16:52:48 -0700 Subject: [PATCH 10/12] 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 6023492b1..ec6c5f275 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 8ebf2c04b..8032a89c5 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4685,13 +4685,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 101210d79..cb709c8b3 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -342,21 +342,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", @@ -413,7 +415,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::>(); @@ -428,7 +430,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 2cd82ff76..96424056f 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 3b2b9492c..e8da5c5ef 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 b15525e37..1d78fe361 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 2353c7e71..b9f949c0c 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1686,11 +1686,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") @@ -1698,26 +1698,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::>(); @@ -1728,7 +1728,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"); @@ -1758,7 +1758,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"] @@ -1767,7 +1767,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() @@ -1865,13 +1865,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"); @@ -1880,10 +1880,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()); } @@ -2423,7 +2423,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 780ecd428..03963b916 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 @@ -995,8 +986,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); } @@ -1264,8 +1259,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); @@ -1275,8 +1274,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, @@ -1290,9 +1293,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"); @@ -1313,7 +1330,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(); @@ -1341,42 +1359,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(); @@ -1388,28 +1397,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()); @@ -1464,11 +1462,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, @@ -2375,8 +2380,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 @@ -4585,7 +4594,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")) @@ -4625,7 +4634,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 a13920518..8389340c0 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 3967f4862..5b68d6b76 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 000000000..cd62fa448 --- /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 49e7120fd..536897e1d 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 ed2291702..f474e3c5a 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 4cde6038d..a2ee57876 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 8120a883f..7ba112e22 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 26f3a411c..ec0ba7e43 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 c7e4ec1760e95b2e2caca26028ed4e65141d7410 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 20:46:55 -0700 Subject: [PATCH 11/12] 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 996ae1565..ddeb7a991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3763,6 +3763,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 7800d6628..24e140a6c 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 e8da5c5ef..e689f5882 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 03963b916..ec2eebc13 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(); @@ -988,7 +988,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?; @@ -1261,7 +1261,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?; @@ -1276,7 +1276,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?; @@ -1300,7 +1300,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 @@ -1308,7 +1308,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(); @@ -1359,7 +1359,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> { @@ -1376,7 +1376,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 @@ -1384,7 +1384,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(); @@ -1464,13 +1464,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?; @@ -2382,7 +2382,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 8389340c0..24673efae 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 5b68d6b76..84a0a775a 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 cd62fa448..000000000 --- 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 000000000..6b0f09cf8 --- /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 137db87eb..a54a65891 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 aae6f8c69..287f442d1 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 f474e3c5a..f06c56eef 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 503967a53..4b90aa7c3 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 a2ee57876..c939c0306 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 7ba112e22..28c004fa2 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 ec0ba7e43..357f55950 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 e6e5a8d35b2c4118496008ba592217201a016ef0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 30 Jun 2026 23:10:46 -0700 Subject: [PATCH 12/12] 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 e689f5882..8bc0b30b4 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 ebe75e434..7e212590d 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 b9f949c0c..90f9aeda6 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -296,6 +296,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, @@ -327,6 +329,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) @@ -417,6 +420,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, @@ -1700,6 +1704,8 @@ pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { #[cfg(test)] mod tests { + use std::collections::HashMap; + use openshell_core::proto::ProviderProfileCategory; use super::{ @@ -2432,6 +2438,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( @@ -2578,6 +2616,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, @@ -2593,6 +2632,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, @@ -2608,6 +2648,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 ec2eebc13..99e51e314 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -4613,6 +4613,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, @@ -4659,6 +4660,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, @@ -4725,6 +4727,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, @@ -4992,6 +4995,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, @@ -5277,6 +5281,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, @@ -5497,6 +5502,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, @@ -7613,6 +7619,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 24673efae..b24137592 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 6b0f09cf8..ade114313 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 536897e1d..89b8b3e59 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 a54a65891..ab85d179e 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 287f442d1..d38b412c8 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 f06c56eef..36b3f84ec 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 407baef22..021e635db 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 c939c0306..e5f070d61 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 28c004fa2..e41500b36 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 000000000..18a45bb9f --- /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 000000000..4b190b448 --- /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 be4bc1464..bd1a70e2d 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.