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..ddeb7a991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3751,6 +3751,27 @@ 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", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tonic", + "tower 0.5.3", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -3876,6 +3897,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..ec6c5f275 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -41,6 +41,24 @@ 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. + +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 | @@ -220,7 +238,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 f56bb7151..8032a89c5 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 { @@ -1970,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 { @@ -2683,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!( " {} {}", @@ -3427,10 +3435,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 +3896,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 +3939,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(), @@ -4670,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 \ @@ -4764,6 +4780,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 +5712,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, @@ -6306,12 +6324,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()? @@ -6504,13 +6518,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()? @@ -6539,13 +6550,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()? @@ -6574,13 +6581,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()? @@ -6609,13 +6613,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()? @@ -6669,12 +6669,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()?; @@ -6843,13 +6838,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()? @@ -9600,6 +9590,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..cb709c8b3 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, @@ -341,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", @@ -412,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::>(); @@ -427,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 @@ -602,6 +608,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 c66d32610..b3215e99d 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,81 @@ 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, +} + +/// 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 { + ModifyOperation, + Validate, + PostCommit, +} + const fn default_jwks_ttl_secs() -> u64 { 3600 } @@ -584,6 +662,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 +766,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)> { @@ -811,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 _}; @@ -891,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-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-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..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, @@ -79,7 +87,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..24e140a6c --- /dev/null +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -0,0 +1,34 @@ +# 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 } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +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 new file mode 100644 index 000000000..8bc0b30b4 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -0,0 +1,2338 @@ +// 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::ProviderProfile; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorResult, InterceptorSelector, JsonPatch, ProviderProfileSnapshotRequest, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost::Message; +use prost_types::{ + DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, + FileDescriptorSet, Struct, + field_descriptor_proto::{Label, Type}, +}; +use serde_json::{Map, Number, Value}; +use sha2::Digest as _; +use tokio::net::UnixStream; +use tonic::codegen::http::Uri; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Code, Request, Status}; +use tower::service_fn; +use tracing::{info, warn}; + +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)] +pub struct ProtoJsonCodec { + descriptors: Arc, +} + +impl ProtoJsonCodec { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + Ok(Self { + descriptors: Arc::new(ProtoDescriptors::from_descriptor_set(bytes)?), + }) + } + + pub fn openshell() -> Result { + Self::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + } + + pub fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: Message, + { + self.descriptors + .decode_message_to_json(type_name, &message.encode_to_vec()) + } + + pub fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + self.descriptors.encode_json_to_message(type_name, value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + ModifyOperation, + Validate, + PostCommit, +} + +impl Phase { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ModifyOperation => "modify_operation", + Self::Validate => "validate", + Self::PostCommit => "post_commit", + } + } + + #[must_use] + pub const fn to_proto(self) -> GatewayInterceptorPhase { + match self { + Self::ModifyOperation => GatewayInterceptorPhase::ModifyOperation, + Self::Validate => GatewayInterceptorPhase::Validate, + Self::PostCommit => GatewayInterceptorPhase::PostCommit, + } + } +} + +impl TryFrom for Phase { + type Error = InterceptorError; + + fn try_from(value: GatewayInterceptorPhase) -> Result { + match value { + GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), + GatewayInterceptorPhase::Validate => Ok(Self::Validate), + GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), + GatewayInterceptorPhase::Unspecified => Err(InterceptorError::Config( + "binding phase must not be unspecified".to_string(), + )), + } + } +} + +impl From for Phase { + fn from(value: GatewayInterceptorPhaseConfig) -> Self { + match value { + GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, + GatewayInterceptorPhaseConfig::Validate => Self::Validate, + GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + FailClosed, + FailOpen, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + } + } +} + +impl From for FailurePolicy { + fn from(value: GatewayInterceptorFailurePolicy) -> Self { + match value { + GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, + GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RpcSelector { + pub service: String, + pub method: String, +} + +impl RpcSelector { + #[must_use] + pub fn new(service: impl Into, method: impl Into) -> Self { + Self { + service: service.into(), + method: method.into(), + } + } + + #[must_use] + pub fn rpc(&self) -> String { + format!("{}/{}", self.service, self.method) + } + + #[must_use] + pub fn from_grpc_path(path: &str) -> Option { + let path = path.strip_prefix('/').unwrap_or(path); + let (service, method) = path.rsplit_once('/')?; + Some(Self::new(service, method)) + } +} + +#[derive(Clone)] +struct BindingPlan { + interceptor_name: String, + binding_id: String, + selector: RpcSelector, + phase: Phase, + failure_policy: FailurePolicy, + timeout: Duration, + max_response_bytes: usize, + max_patches: usize, + client: GatewayInterceptorClient, +} + +impl std::fmt::Debug for BindingPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BindingPlan") + .field("interceptor_name", &self.interceptor_name) + .field("binding_id", &self.binding_id) + .field("selector", &self.selector) + .field("phase", &self.phase) + .field("failure_policy", &self.failure_policy) + .field("timeout", &self.timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("max_patches", &self.max_patches) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub struct GatewayInterceptorRuntime { + bindings: Arc>>, + profile_sources: Arc>, + routes: Arc, + descriptors: Arc, +} + +#[derive(Debug, Clone)] +pub struct EvaluationContext { + pub principal: BTreeMap, + pub current_state: Option, +} + +#[derive(Debug, Clone)] +pub struct InterceptedRequest { + pub body: Vec, + selector: RpcSelector, + operation: Value, +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileSourceSnapshot { + pub source_id: String, + 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. +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(); + let mut profile_sources = Vec::new(); + let mut profile_source_ids = BTreeSet::new(); + + for config in configs { + validate_service_config(&config)?; + let channel = connect_endpoint(&config.grpc_endpoint).await?; + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; + let mut client = GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size( + config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), + ); + let manifest = + tokio::time::timeout(timeout, client.describe(Request::new(DescribeRequest {}))) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "Describe timed out for '{}'", + config.name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); + let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; + let service_default = config + .failure_policy + .map(FailurePolicy::from) + .or(manifest_default) + .unwrap_or(FailurePolicy::FailClosed); + let max_response_bytes = config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + let max_patches = config.max_patches.unwrap_or(DEFAULT_MAX_PATCHES); + + let override_index = OverrideIndex::new(&config.bindings)?; + for manifest_binding in &manifest.bindings { + let normalized = normalize_binding( + &config.name, + manifest_binding, + service_default, + &override_index, + )?; + let Some(normalized) = normalized else { + continue; + }; + if !routes + .is_interceptable(&normalized.selector.service, &normalized.selector.method) + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' targets non-interceptable RPC '{}'", + config.name, + normalized.binding_id, + normalized.selector.rpc() + ))); + } + for phase in normalized.phases { + let plan = BindingPlan { + interceptor_name: config.name.clone(), + binding_id: normalized.binding_id.clone(), + selector: normalized.selector.clone(), + phase, + failure_policy: normalized.failure_policy, + timeout, + max_response_bytes, + max_patches, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }; + bindings + .entry((normalized.selector.clone(), phase)) + .or_default() + .push(plan); + } + } + + if manifest.provider_profiles { + let source_id = format!("interceptor/{}", config.name); + if !profile_source_ids.insert(source_id.clone()) { + return Err(InterceptorError::Config(format!( + "duplicate provider profile source id '{source_id}'" + ))); + } + profile_sources.push(ProfileSourcePlan { + interceptor_name: config.name.clone(), + source_id, + timeout, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }); + } + } + + let count: usize = bindings.values().map(Vec::len).sum(); + info!( + bindings = count, + profile_sources = profile_sources.len(), + "gateway interceptors initialized" + ); + Ok(Self { + bindings: Arc::new(bindings), + profile_sources: Arc::new(profile_sources), + routes: Arc::new(routes), + descriptors: Arc::new(descriptors), + }) + } + + #[must_use] + pub fn 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.profile_sources.is_empty() + } + + #[must_use] + pub fn should_intercept_path(&self, path: &str) -> bool { + let Some(selector) = RpcSelector::from_grpc_path(path) else { + return false; + }; + self.routes + .is_interceptable(&selector.service, &selector.method) + && [Phase::ModifyOperation, Phase::Validate, Phase::PostCommit] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + } + + pub async fn evaluate_request( + &self, + path: &str, + body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result { + let selector = RpcSelector::from_grpc_path(path) + .ok_or_else(|| Status::invalid_argument("invalid gRPC method path"))?; + let input_type = self + .routes + .input_type(&selector.service, &selector.method) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? + .to_string(); + let frame = GrpcFrame::decode(body)?; + let 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::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); + emit_evaluation_log(plan, &result, "deny", 0); + return Err(status_from_result(&result, reason)); + } + + if phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + match apply_json_patches(&operation, &result.patches) { + Ok(patched) => { + operation = patched; + emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, &result, "allow", patch_count); + } + Err(err) => { + apply_failure_policy(plan, &err)?; + } + } + } else { + emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, &result, "allow", 0); + } + } + 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_optional_failure_policy(&binding.failure_policy)?.unwrap_or(service_default); + + if let Some(override_cfg) = overrides.get(binding_id, &selector) { + if let Some(override_selector) = override_selector(override_cfg)? + && override_selector != selector + { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot widen selector '{}' to '{}'", + selector.rpc(), + override_selector.rpc() + ))); + } + if override_cfg.disabled { + return Ok(None); + } + if let Some(override_phases) = &override_cfg.phases { + let override_set: BTreeSet = + override_phases.iter().copied().map(Phase::from).collect(); + let declared: BTreeSet = phases.iter().copied().collect(); + if !override_set.is_subset(&declared) { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot add phases not declared by the manifest" + ))); + } + phases = override_set.into_iter().collect(); + } + if let Some(policy) = override_cfg.failure_policy { + failure_policy = policy.into(); + } + } + + Ok(Some(NormalizedBinding { + binding_id: binding_id.to_string(), + selector, + phases, + failure_policy, + })) +} + +fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { + let selector = selector + .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; + if !selector.rpc.trim().is_empty() { + return parse_rpc_selector(&selector.rpc); + } + if selector.service.trim().is_empty() || selector.method.trim().is_empty() { + return Err(InterceptorError::Config( + "binding selector requires rpc or service+method".to_string(), + )); + } + Ok(RpcSelector::new( + selector.service.trim(), + selector.method.trim(), + )) +} + +fn provider_profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = sha2::Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +fn override_selector( + override_cfg: &GatewayInterceptorBindingOverride, +) -> Result> { + if let Some(rpc) = override_cfg.rpc.as_deref() + && !rpc.trim().is_empty() + { + return parse_rpc_selector(rpc).map(Some); + } + match ( + override_cfg + .service + .as_deref() + .filter(|v| !v.trim().is_empty()), + override_cfg + .method + .as_deref() + .filter(|v| !v.trim().is_empty()), + ) { + (Some(service), Some(method)) => Ok(Some(RpcSelector::new(service.trim(), method.trim()))), + (None, None) => Ok(None), + _ => Err(InterceptorError::Config( + "binding override selector requires both service and method".to_string(), + )), + } +} + +fn parse_rpc_selector(value: &str) -> Result { + let (service, method) = value.trim().split_once('/').ok_or_else(|| { + InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + )) + })?; + if service.is_empty() || method.is_empty() || method.contains('/') { + return Err(InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + ))); + } + Ok(RpcSelector::new(service, method)) +} + +fn parse_optional_failure_policy(value: &str) -> Result> { + match value.trim() { + "" => Ok(None), + "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), + "fail_open" => Ok(Some(FailurePolicy::FailOpen)), + other => Err(InterceptorError::Config(format!( + "unsupported failure_policy '{other}'" + ))), + } +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(InterceptorError::Config( + "timeout must not be empty".to_string(), + )); + } + if let Some(ms) = value.strip_suffix("ms") { + let millis = ms + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_millis(millis)); + } + if let Some(seconds) = value.strip_suffix('s') { + let seconds = seconds + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_secs(seconds)); + } + Err(InterceptorError::Config(format!( + "invalid timeout '{value}'; expected suffix ms or s" + ))) +} + +async fn connect_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if let Some(path) = endpoint.strip_prefix("unix://") { + return connect_unix_endpoint(PathBuf::from(path)).await; + } + Endpoint::from_shared(endpoint.to_string()) + .map_err(|e| { + InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) + })? + .connect() + .await + .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) +} + +#[cfg(unix)] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + let display = path.display().to_string(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) +} + +#[cfg(not(unix))] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + Err(InterceptorError::Config(format!( + "unix interceptor endpoints are not supported on this platform: {}", + path.display() + ))) +} + +async fn evaluate_plan( + plan: &BindingPlan, + operation: Value, + context: &EvaluationContext, +) -> Result { + let operation = json_to_struct(operation)?; + let current_state = 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(()) + } + } +} + +fn validate_result_contract(plan: &BindingPlan, result: &InterceptorResult) -> Result<()> { + if result.patches.len() > plan.max_patches { + return Err(InterceptorError::InvalidResult(format!( + "interceptor returned too many patches ({} > {})", + result.patches.len(), + plan.max_patches + ))); + } + if plan.phase != Phase::ModifyOperation && !result.patches.is_empty() { + return Err(InterceptorError::InvalidResult(format!( + "patches are invalid during {}", + plan.phase.as_str() + ))); + } + if plan.phase == Phase::PostCommit && (!result.allowed || !result.patches.is_empty()) { + return Err(InterceptorError::InvalidResult( + "post_commit cannot deny or mutate operations".to_string(), + )); + } + Ok(()) +} + +fn status_from_result(result: &InterceptorResult, reason: String) -> Status { + let code = grpc_code_from_name(&result.status_code).unwrap_or(Code::PermissionDenied); + Status::new(code, reason) +} + +fn grpc_code_from_name(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "OK" => Some(Code::Ok), + "CANCELLED" => Some(Code::Cancelled), + "UNKNOWN" => Some(Code::Unknown), + "INVALID_ARGUMENT" => Some(Code::InvalidArgument), + "DEADLINE_EXCEEDED" => Some(Code::DeadlineExceeded), + "NOT_FOUND" => Some(Code::NotFound), + "ALREADY_EXISTS" => Some(Code::AlreadyExists), + "PERMISSION_DENIED" => Some(Code::PermissionDenied), + "RESOURCE_EXHAUSTED" => Some(Code::ResourceExhausted), + "FAILED_PRECONDITION" => Some(Code::FailedPrecondition), + "ABORTED" => Some(Code::Aborted), + "OUT_OF_RANGE" => Some(Code::OutOfRange), + "UNIMPLEMENTED" => Some(Code::Unimplemented), + "INTERNAL" => Some(Code::Internal), + "UNAVAILABLE" => Some(Code::Unavailable), + "DATA_LOSS" => Some(Code::DataLoss), + "UNAUTHENTICATED" => Some(Code::Unauthenticated), + _ => None, + } +} + +fn json_patch_operations(patches: &[JsonPatch]) -> Result> { + let mut raw = Vec::with_capacity(patches.len()); + for patch in patches { + let mut op = Map::new(); + op.insert("op".to_string(), Value::String(patch.op.clone())); + op.insert("path".to_string(), Value::String(patch.path.clone())); + if !patch.from.is_empty() { + op.insert("from".to_string(), Value::String(patch.from.clone())); + } + if let Some(value) = patch.value.as_ref() { + op.insert("value".to_string(), protobuf_value_to_json(value)); + } + raw.push(Value::Object(op)); + } + serde_json::from_value(Value::Array(raw)) + .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) +} + +fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result { + let patch_ops = json_patch_operations(patches)?; + let mut candidate = operation.clone(); + patch(&mut candidate, &patch_ops) + .map_err(|err| InterceptorError::InvalidResult(format!("invalid JSON patch: {err}")))?; + Ok(candidate) +} + +fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) { + counter!( + "openshell_gateway_interceptor_evaluations_total", + "decision" => result.to_string(), + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(1); + if patch_count > 0 { + counter!( + "openshell_gateway_interceptor_patches_total", + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(patch_count as u64); + } +} + +fn emit_evaluation_log( + plan: &BindingPlan, + result: &InterceptorResult, + decision: &str, + patch_count: usize, +) { + info!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + service = %plan.selector.service, + method = %plan.selector.method, + decision, + patch_count, + log_annotations = ?result.log_annotations, + "gateway interceptor evaluated" + ); +} + +#[derive(Debug, Clone)] +struct GrpcFrame { + compressed: bool, + message: Vec, +} + +impl GrpcFrame { + fn decode(body: &[u8]) -> std::result::Result { + if body.len() < GRPC_HEADER_LEN { + return Err(Status::invalid_argument("gRPC 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 { + 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; + 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> { + if let Some(encoded) = encode_well_known_json(type_name, value) { + return encoded; + } + + let message = self.message(type_name)?; + let Value::Object(map) = value else { + return Err(InterceptorError::Transcode(format!( + "{type_name} JSON must be an object" + ))); + }; + let mut 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_list_value(value: Value) -> Result { + match value { + Value::Array(values) => Ok(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "google.protobuf.ListValue JSON must be an array".to_string(), + )), + } +} + +fn json_to_protobuf_value(value: Value) -> Result { + let kind = match value { + Value::Null => prost_types::value::Kind::NullValue(0), + Value::Bool(value) => prost_types::value::Kind::BoolValue(value), + Value::Number(value) => prost_types::value::Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode("invalid JSON number".to_string()))?, + ), + Value::String(value) => prost_types::value::Kind::StringValue(value), + Value::Array(values) => prost_types::value::Kind::ListValue(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + Value::Object(fields) => prost_types::value::Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + }; + Ok(prost_types::Value { kind: Some(kind) }) +} + +fn decode_well_known_json(type_name: &str, bytes: &[u8]) -> Option> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => Some( + Struct::decode(bytes) + .map(|value| protobuf_struct_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Struct bytes: {err}" + )) + }), + ), + "google.protobuf.Value" => Some( + prost_types::Value::decode(bytes) + .map(|value| protobuf_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.Value bytes: {err}" + )) + }), + ), + "google.protobuf.ListValue" => Some( + prost_types::ListValue::decode(bytes) + .map(|value| protobuf_list_value_to_json(&value)) + .map_err(|err| { + InterceptorError::Transcode(format!( + "invalid google.protobuf.ListValue bytes: {err}" + )) + }), + ), + _ => None, + } +} + +fn encode_well_known_json(type_name: &str, value: &Value) -> Option>> { + match trim_type_name(type_name) { + "google.protobuf.Struct" => { + Some(json_to_struct(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.Value" => { + Some(json_to_protobuf_value(value.clone()).map(|value| value.encode_to_vec())) + } + "google.protobuf.ListValue" => { + Some(json_to_list_value(value.clone()).map(|value| value.encode_to_vec())) + } + _ => None, + } +} + +fn protobuf_struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), protobuf_value_to_json(value))) + .collect(), + ) +} + +fn protobuf_list_value_to_json(value: &prost_types::ListValue) -> Value { + Value::Array(value.values.iter().map(protobuf_value_to_json).collect()) +} + +fn protobuf_value_to_json(value: &prost_types::Value) -> Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, + Some(prost_types::value::Kind::NumberValue(value)) => number_json(*value), + Some(prost_types::value::Kind::StringValue(value)) => Value::String(value.clone()), + Some(prost_types::value::Kind::BoolValue(value)) => Value::Bool(*value), + Some(prost_types::value::Kind::StructValue(value)) => protobuf_struct_to_json(value), + Some(prost_types::value::Kind::ListValue(value)) => protobuf_list_value_to_json(value), + } +} + +fn number_json(value: f64) -> Value { + Number::from_f64(value).map_or(Value::Null, Value::Number) +} + +fn expect_wire(actual: u8, expected: u8) -> Result<()> { + if actual == expected { + Ok(()) + } else { + Err(InterceptorError::Transcode(format!( + "wire type mismatch: got {actual}, expected {expected}" + ))) + } +} + +fn decode_varint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let Some((&byte, rest)) = input.split_first() else { + return Err(InterceptorError::Transcode("truncated varint".to_string())); + }; + *input = rest; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err(InterceptorError::Transcode("varint overflow".to_string())) +} + +fn decode_fixed32(input: &mut &[u8]) -> Result { + if input.len() < 4 { + return Err(InterceptorError::Transcode("truncated fixed32".to_string())); + } + let (bytes, rest) = input.split_at(4); + *input = rest; + Ok(u32::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_fixed64(input: &mut &[u8]) -> Result { + if input.len() < 8 { + return Err(InterceptorError::Transcode("truncated fixed64".to_string())); + } + let (bytes, rest) = input.split_at(8); + *input = rest; + Ok(u64::from_le_bytes( + bytes.try_into().expect("length checked"), + )) +} + +fn decode_length_delimited(input: &mut &[u8]) -> Result> { + let len = usize::try_from(decode_varint(input)?) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?; + if input.len() < len { + return Err(InterceptorError::Transcode( + "truncated length-delimited field".to_string(), + )); + } + let (bytes, rest) = input.split_at(len); + *input = rest; + Ok(bytes.to_vec()) +} + +fn skip_unknown(wire_type: u8, input: &mut &[u8]) -> Result<()> { + match wire_type { + 0 => { + decode_varint(input)?; + } + 1 => { + decode_fixed64(input)?; + } + 2 => { + decode_length_delimited(input)?; + } + 5 => { + decode_fixed32(input)?; + } + other => { + return Err(InterceptorError::Transcode(format!( + "unsupported unknown wire type {other}" + ))); + } + } + Ok(()) +} + +fn decode_zigzag32(value: u32) -> i32 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn decode_zigzag64(value: u64) -> i64 { + (value >> 1).cast_signed() ^ -((value & 1).cast_signed()) +} + +fn encode_zigzag32(value: i32) -> u32 { + ((value << 1) ^ (value >> 31)).cast_unsigned() +} + +fn encode_zigzag64(value: i64) -> u64 { + ((value << 1) ^ (value >> 63)).cast_unsigned() +} + +fn encode_key(field_number: u32, wire_type: u8, out: &mut Vec) { + encode_varint((u64::from(field_number) << 3) | u64::from(wire_type), out); +} + +fn encode_varint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + let byte = u8::try_from(value & 0x7f).expect("masked varint byte fits u8"); + out.push(byte | 0x80); + value >>= 7; + } + out.push(u8::try_from(value).expect("final varint byte fits u8")); +} + +fn encode_length_delimited(bytes: &[u8], out: &mut Vec) -> Result<()> { + encode_varint( + u64::try_from(bytes.len()) + .map_err(|_| InterceptorError::Transcode("length overflow".to_string()))?, + out, + ); + out.extend_from_slice(bytes); + Ok(()) +} + +fn json_string<'a>(value: &'a Value, field: &str) -> Result<&'a str> { + value + .as_str() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a string"))) +} + +fn json_bool(value: &Value, field: &str) -> Result { + value + .as_bool() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a bool"))) +} + +fn json_f64(value: &Value, field: &str) -> Result { + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be a number"))) +} + +#[allow(clippy::cast_possible_truncation)] +fn json_f32(value: &Value, field: &str) -> Result { + let value = json_f64(value, field)?; + if value.is_finite() && value >= f64::from(f32::MIN) && value <= f64::from(f32::MAX) { + Ok(value as f32) + } else { + Err(InterceptorError::Transcode(format!( + "{field} must be a finite float" + ))) + } +} + +fn json_i64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be int64 string"))), + Value::Number(value) => value + .as_i64() + .or_else(|| integral_f64(value).and_then(|value| i64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be int64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be int64" + ))), + } +} + +fn json_u64(value: &Value, field: &str) -> Result { + match value { + Value::String(value) => value + .parse() + .map_err(|_| InterceptorError::Transcode(format!("{field} must be uint64 string"))), + Value::Number(value) => value + .as_u64() + .or_else(|| integral_f64(value).and_then(|value| u64::try_from(value).ok())) + .ok_or_else(|| InterceptorError::Transcode(format!("{field} must be uint64"))), + _ => Err(InterceptorError::Transcode(format!( + "{field} must be uint64" + ))), + } +} + +fn integral_f64(value: &Number) -> Option { + let value = value.as_f64()?; + if value.fract() == 0.0 && value.is_finite() { + format!("{value:.0}").parse().ok() + } else { + None + } +} + +fn json_i32(value: &Value, field: &str) -> Result { + i32::try_from(json_i64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds int32"))) +} + +fn json_u32(value: &Value, field: &str) -> Result { + u32::try_from(json_u64(value, field)?) + .map_err(|_| InterceptorError::Transcode(format!("{field} exceeds uint32"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{ + CreateSandboxRequest, SandboxSpec, SandboxTemplate, UpdateConfigRequest, + }; + 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() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert!(parse_duration("2").is_err()); + } + + #[test] + fn service_default_failure_policy_rejects_ignore() { + let err = parse_optional_failure_policy("ignore").unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + + #[test] + fn 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'" + ); + } + + #[tokio::test] + async fn evaluation_log_emits_structured_log_annotations() { + let log_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let writer = TraceBuf(log_buf.clone()); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .without_time(); + let subscriber = tracing_subscriber::registry().with(fmt_layer); + let dispatch = tracing::Dispatch::new(subscriber); + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailClosed, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let result = InterceptorResult { + allowed: true, + log_annotations: HashMap::from([ + ( + "correlation_id".to_string(), + "governance:create-sandbox:demo".to_string(), + ), + ("policy_hash".to_string(), "abc123".to_string()), + ]), + ..InterceptorResult::default() + }; + + tracing::dispatcher::with_default(&dispatch, || { + emit_evaluation_log(&plan, &result, "allow", 2); + }); + + let output = String::from_utf8(log_buf.lock().unwrap().clone()).unwrap(); + assert!(output.contains("gateway interceptor evaluated")); + assert!(output.contains("log_annotations")); + assert!(output.contains("correlation_id")); + assert!(output.contains("governance:create-sandbox:demo")); + assert!(output.contains("policy_hash")); + } + + #[test] + fn dynamic_create_sandbox_round_trip_uses_json_names() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::from([("team".to_string(), "agent".to_string())]), + annotations: HashMap::new(), + }; + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + assert_eq!(json["spec"]["providers"][0], "github"); + assert_eq!(json["labels"]["team"], "agent"); + let encoded = descriptors + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn dynamic_update_config_round_trip_preserves_annotations() { + let descriptors = + ProtoDescriptors::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = UpdateConfigRequest { + name: "demo".to_string(), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + let bytes = request.encode_to_vec(); + let json = descriptors + .decode_message_to_json("openshell.v1.UpdateConfigRequest", &bytes) + .unwrap(); + assert_eq!( + json["annotations"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + let encoded = descriptors + .encode_json_to_message("openshell.v1.UpdateConfigRequest", &json) + .unwrap(); + let decoded = UpdateConfigRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn 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-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-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/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..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, @@ -1686,11 +1690,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 +1702,28 @@ 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 std::collections::HashMap; + 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 +1734,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 +1764,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 +1773,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 +1871,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 +1886,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 +2429,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"); @@ -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/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/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..99e51e314 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_sources::ProviderProfileSources; 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}; @@ -71,7 +72,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}; @@ -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: &ProviderProfileSources, 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_sources, + &provider_names, + ) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1049,6 +1044,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, @@ -1209,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_sources, + &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); @@ -1220,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_sources, + &sandbox_provider_names, + ) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1235,8 +1293,22 @@ 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, + &ProviderProfileSources::with_default_sources(), + provider_names, + ) + .await +} + +pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, + catalog: &ProviderProfileSources, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1258,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(); @@ -1286,42 +1359,33 @@ pub(super) async fn compute_provider_env_revision( async fn hash_provider_profile_revision( store: &Store, + catalog: &ProviderProfileSources, 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, + &ProviderProfileSources::with_default_sources(), + provider_names, + ) + .await +} + +async fn profile_provider_policy_layers_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, + provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); @@ -1333,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()); @@ -1409,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_sources, + &provider_names, + ) + .await?; + let provider_environment = super::provider::resolve_provider_environment_with_catalog( + state.store.as_ref(), + &state.provider_profile_sources, + &provider_names, + ) + .await?; info!( sandbox_id = %sandbox_id, @@ -1458,6 +1518,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 +1551,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 +1601,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 +1657,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 +1706,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 +1729,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 +1763,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 +1806,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 +1845,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 +1888,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 +1934,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 +1946,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 +1974,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 +2006,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 +2025,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, + )) } // --------------------------------------------------------------------------- @@ -2254,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_sources, + &provider_names_for_creds, + ) + .await?; let current_version = state .store @@ -4063,6 +4193,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 +4227,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 +4260,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 +4296,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 +4384,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 +4465,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 +4492,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 +4536,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4456,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")) @@ -4470,10 +4608,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Generic Override".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4495,7 +4635,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] @@ -4514,10 +4655,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4579,10 +4722,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4845,10 +4990,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Policy".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5129,10 +5276,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Token".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5353,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, @@ -5530,6 +5680,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 +5770,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 +5875,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 +6091,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 +6188,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 +6304,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 +6508,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 +6606,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 +6712,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 +6806,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 +6892,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 +6980,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 +7068,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 +7161,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 +7336,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 +7433,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 +7519,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,10 +7614,12 @@ 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(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -7485,6 +7654,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 +7777,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 +7964,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 +8079,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 +8180,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 +8295,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 +8312,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 +9922,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 @@ -9780,6 +9957,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + annotations: HashMap::new(), }), ) .await @@ -9807,6 +9985,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; @@ -9852,6 +10448,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -9955,6 +10552,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -9989,6 +10587,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + annotations: HashMap::new(), }), ) .await @@ -10036,6 +10635,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10074,6 +10674,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, + annotations: HashMap::new(), }), ) .await diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c90..b24137592 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_sources::{ + ProviderProfileSources, 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, + &ProviderProfileSources::with_default_sources(), + provider, + ) + .await +} + +pub(super) async fn create_provider_record_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -78,6 +96,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(), }); } @@ -98,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", @@ -176,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, + &ProviderProfileSources::with_default_sources(), + provider, + ) + .await +} + +pub(super) async fn update_provider_record_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, + provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -230,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(); @@ -431,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, + &ProviderProfileSources::with_default_sources(), + provider_names, + ) + .await +} + +pub(super) async fn resolve_provider_environment_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, + provider_names: &[String], ) -> Result { if provider_names.is_empty() { return Ok(ProviderEnvironment::default()); @@ -442,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 { @@ -495,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?, }) } @@ -504,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: &ProviderProfileSources, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -527,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; }; @@ -817,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, + &ProviderProfileSources::with_default_sources(), + provider_names, + ) + .await +} + +pub async fn validate_provider_environment_keys_unique_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, + provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, + catalog, provider_names, None, crate::persistence::current_time_ms(), @@ -827,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: &ProviderProfileSources, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -838,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, + &ProviderProfileSources::with_default_sources(), + provider, + ) + .await +} + +pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( + store: &Store, + catalog: &ProviderProfileSources, + provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { @@ -853,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(), @@ -870,6 +956,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( async fn validate_provider_environment_keys_unique_at( store: &Store, + catalog: &ProviderProfileSources, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -899,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(()) @@ -915,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: &ProviderProfileSources, 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( @@ -1141,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}; @@ -1161,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_sources, + provider, + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1207,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, @@ -1220,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_sources + .list_profiles(state.store.as_ref()) + .await? .into_iter() .skip(offset) .take(limit) - .map(|profile| profile.to_proto()) .collect(); Ok(Response::new(ListProviderProfilesResponse { profiles })) @@ -1238,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_sources + .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), @@ -1256,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_sources, + &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_sources, + &profiles, + "import", + ) + .await?, ); } @@ -1314,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_sources, + &profiles, + &target_id, + ) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1342,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_sources, + &profiles, + "update", + ) + .await?, ); } @@ -1416,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_sources, + &profiles, + ) + .await?, + ); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1432,10 +1556,14 @@ 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_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" + ))); } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; @@ -1465,38 +1593,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: &ProviderProfileSources, 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: &ProviderProfileSources, 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 @@ -1539,41 +1655,17 @@ fn validate_refresh_material( async fn provider_type_allows_empty_credentials( store: &Store, + catalog: &ProviderProfileSources, 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")); @@ -1625,6 +1717,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1632,12 +1725,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(store, &id).await? { 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; @@ -1662,6 +1757,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1682,12 +1778,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(store, target_id).await? { 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); @@ -1710,12 +1808,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(store, &id).await? { 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(), }); } @@ -1725,6 +1825,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, + catalog: &ProviderProfileSources, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1775,7 +1876,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?, + ); } } @@ -1801,42 +1907,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, - }), - 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, @@ -1904,7 +1974,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_sources, + provider, + ) + .await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2058,14 +2133,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_sources, + &provider, + credential_key, + ) + .await?; + let refresh_defaults = provider_refresh_defaults( state.store.as_ref(), + &state.provider_profile_sources, &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 @@ -2146,6 +2227,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 +2323,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(), @@ -2453,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, @@ -2526,6 +2610,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 +2688,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2766,7 +2852,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( @@ -2918,6 +3004,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2980,6 +3067,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: [ @@ -3002,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, @@ -3269,7 +3358,7 @@ mod tests { response .diagnostics .iter() - .any(|diagnostic| diagnostic.message.contains("built-in")) + .any(|diagnostic| diagnostic.message.contains("managed by source 'builtin'")) ); } @@ -3441,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, @@ -3589,6 +3679,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 +3715,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 +3828,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 +3892,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 +3936,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 +3989,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 +4012,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 +4032,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 +4084,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 +4104,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 +4171,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 +4239,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 +4391,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 +4514,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 +4559,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 +4589,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 +4624,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 +4645,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(), @@ -4556,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, @@ -4612,6 +4721,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 +4758,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 +4795,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 +4816,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 +4843,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 +4872,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 +4920,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 +4972,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 +5002,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 +5034,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 +5063,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 +5081,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 +5113,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: [ @@ -5046,6 +5168,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5095,6 +5218,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5129,6 +5253,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 +5276,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 +5310,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 +5330,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 +5369,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 +5444,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 +5489,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 +5538,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 +5586,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 +5625,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 +5648,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 +5669,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 +5688,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 +5723,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 +5745,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 +5782,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 +5830,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 +6178,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 +6281,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/lib.rs b/crates/openshell-server/src/lib.rs index 13f5c647c..84a0a775a 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_sources; mod provider_refresh; mod readiness; mod sandbox_index; @@ -147,6 +148,14 @@ 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, + + /// 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 { @@ -197,6 +206,9 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + gateway_interceptors: None, + provider_profile_sources: + provider_profile_sources::ProviderProfileSources::with_default_sources(), } } } @@ -263,6 +275,16 @@ 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 provider_profile_sources = + provider_profile_sources::ProviderProfileSources::from_gateway_interceptors( + gateway_interceptors.clone(), + ); let mut state = ServerState::new( config.clone(), store.clone(), @@ -273,6 +295,8 @@ pub(crate) async fn run_server( supervisor_sessions, oidc_cache, ); + state.gateway_interceptors = gateway_interceptors; + 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/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 9e70c6472..ee7cfaab5 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, LengthLimitError, Limited}; 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}; @@ -120,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)] @@ -154,6 +158,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 +229,177 @@ 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 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 { + 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) + }) + } +} + +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 {} }) + .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, @@ -886,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; @@ -963,7 +1150,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 +1182,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 +1563,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/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_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs new file mode 100644 index 000000000..ade114313 --- /dev/null +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -0,0 +1,470 @@ +// 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 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 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 fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { + profile.resource_version = 0; + profile +} + +pub fn profile_response_payload( + mut profile: ProviderProfile, + resource_version: u64, +) -> ProviderProfile { + profile.resource_version = resource_version; + profile +} + +pub 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/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/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 7992666d3..0c80c379d 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, @@ -2076,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; @@ -2112,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; @@ -2182,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; @@ -2222,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; @@ -2347,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/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 2aaa6e7b0..f86faf1f8 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 = ["modify_operation", "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` 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. ## Driver References diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 49e7120fd..89b8b3e59 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 @@ -157,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 new file mode 100644 index 000000000..ab85d179e --- /dev/null +++ b/examples/governance-interceptor/Cargo.lock @@ -0,0 +1,2192 @@ +# 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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[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 = "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 = "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" +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" +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 = "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" +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 = "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" +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 2.0.18", + "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +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", + "prost-types", + "rcgen", + "serde", + "serde_json", + "serde_yml", + "sha2", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openshell-providers" +version = "0.0.0" +dependencies = [ + "glob", + "openshell-core", + "serde", + "serde_json", + "serde_yml", + "thiserror 2.0.18", + "url", +] + +[[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 = "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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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" +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 = "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" +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" +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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[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 = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "time" +version = "0.3.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" +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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[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 = "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" +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 = "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" +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..d38b412c8 --- /dev/null +++ b/examples/governance-interceptor/Cargo.toml @@ -0,0 +1,31 @@ +# 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] +jsonwebtoken = "9" +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-gateway-interceptors = { path = "../../crates/openshell-gateway-interceptors" } +openshell-policy = { path = "../../crates/openshell-policy" } +openshell-providers = { path = "../../crates/openshell-providers" } +prost = "0.14" +prost-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"] } + +[[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..36b3f84ec --- /dev/null +++ b/examples/governance-interceptor/README.md @@ -0,0 +1,97 @@ +# Governance Interceptor Example + +This standalone example implements the +`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates +how an interceptor can vend provider profiles and make them the gateway's +authoritative profile source. + +- 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 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 + audit logs, plus non-secret policy hash/signing key metadata +- users cannot replace or merge sandbox policy after sandbox creation +- users cannot import or update provider profiles outside the vended set +- provider profile deletion is blocked by the interceptor + +Run the interceptor: + +```shell +cargo run -- \ + --listen 127.0.0.1:18081 \ + --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 +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. + +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 +profile ID `github`, and `profiles/slack.yaml` becomes profile ID `slack`. The +YAML files do not need an `id` field; if one is present, the filename still wins. + +The interceptor advertises `provider_profiles = true` in its manifest and vends +the current profile set through `SnapshotProviderProfiles`. While the +interceptor is attached, the gateway uses that snapshot as the profile source: +`provider list-profiles` shows only `github` and `slack`, and built-in/user +sources are omitted. The example signs each profile's canonical protobuf +payload and exposes the JWT under +`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: + +```toml +[[openshell.gateway.interceptors]] +name = "provider-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 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/policy.yaml b/examples/governance-interceptor/policy.yaml new file mode 100644 index 000000000..021e635db --- /dev/null +++ b/examples/governance-interceptor/policy.yaml @@ -0,0 +1,28 @@ +# 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: + 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/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml new file mode 100644 index 000000000..4b90aa7c3 --- /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-1.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 new file mode 100755 index 000000000..e5f070d61 --- /dev/null +++ b/examples/governance-interceptor/smoke.sh @@ -0,0 +1,625 @@ +#!/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" +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" +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" +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" "$PROFILE_DIR" +cp "$EXAMPLE_DIR"/profiles/*.yaml "$PROFILE_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 + 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 governance interceptor launcher" >&2 + exit 1 +} + +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" + +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 "setup log" "$SETUP_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + dump_logs + exit 1 +} + +log_command() { + local label="$1" + shift + + { + printf '\n== %s ==\n' "$label" + printf '+' + printf ' %q' "$@" + printf '\n' + } >>"$SETUP_LOG" +} + +run_setup_step() { + local label="$1" + shift + + printf 'INFO %s\n' "$label" + log_command "$label" "$@" + if ! "$@" >>"$SETUP_LOG" 2>&1; then + fail "$label" + fi +} + +run_step() { + local label="$1" + shift + + log_command "$label" "$@" + if "$@" >>"$SETUP_LOG" 2>&1; then + pass "$label" + else + fail "$label" + fi +} + +expect_failure() { + local label="$1" + shift + + log_command "$label" "$@" + if "$@" >>"$SETUP_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" + + log_command "$label" "$@" + 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" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" + fi +} + +expect_log_contains() { + local label="$1" + local needle="$2" + local path="$3" + + if grep -Fq -- "$needle" "$path"; then + pass "$label" + else + fail "$label" + 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" + + { + printf '\n== %s ==\n' "$label" + printf '+ wait for provider profile %q\n' "$profile_id" + } >>"$SETUP_LOG" + + for _ in {1..60}; do + if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then + printf 'INFO %s\n' "$label" + return + fi + sleep 1 + done + + fail "$label" +} + +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 '%s\n' "$RUN_ID" >"$JWT_DIR/kid" +} + +write_gateway_config() { + cat >"$GATEWAY_CONFIG" <"$INTERCEPTOR_LOG" 2>&1 & + INTERCEPTOR_PID=$! +} + +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 + printf 'INFO %s\n' "$label" + return + fi + + sleep 1 + done + + fail "$label" +} + +configure_gateway() { + CLI=( + env + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "$GATEWAY_ENDPOINT" + ) + + 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 + 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 +display_name: Custom Slack +description: Profile outside the managed github/slack set used to verify interceptor import denial +category: messaging +credentials: [] +endpoints: [] +binaries: [] +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" + + 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 bitbucket --credential BITBUCKET_TOKEN=dummy + + 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_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_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" +} + +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 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 +wait_for_gateway +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 new file mode 100644 index 000000000..e41500b36 --- /dev/null +++ b/examples/governance-interceptor/src/main.rs @@ -0,0 +1,1358 @@ +// 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::{Duration, SystemTime, UNIX_EPOCH}; + +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, + ProviderProfileSnapshot, ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, +}; +use openshell_core::proto::{ + ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, + UpdateConfigRequest, open_shell_client::OpenShellClient, +}; +use openshell_gateway_interceptors::ProtoJsonCodec; +use openshell_policy::parse_sandbox_policy; +use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; +use policy_hash::deterministic_policy_hash; +use 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}; +use sha2::{Digest, Sha256}; +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 { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + kid: String, +} + +impl std::fmt::Debug for PolicySigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> 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, +} + +#[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) + .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 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}"))?; + 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(()) + } + + #[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_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, + profiles: Vec, + revision: String, +} + +#[derive(Clone, Debug)] +struct LoadedProviderProfile { + profile: ProviderProfile, +} + +impl GovernanceInterceptorService { + #[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 policy_signer = PolicySigner::generate()?; + let profile_state = profile_state_from_loaded(profiles, &policy_signer)?; + let policy_state = load_policy_state(policy_yaml, &policy_signer)?; + Ok(Self { + policy_signer, + policy_state: Arc::new(RwLock::new(policy_state)), + profiles_path, + profile_state: Arc::new(RwLock::new(profile_state)), + }) + } + + fn manifest(&self) -> InterceptorManifest { + InterceptorManifest { + name: "provider-governance".to_string(), + failure_policy: "fail_closed".to_string(), + provider_profiles: true, + bindings: vec![ + binding( + "govern-create-sandbox", + "CreateSandbox", + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + binding( + "govern-create-provider", + "CreateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-config", + "UpdateConfig", + &[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], + ), + ], + } + } + + fn evaluate_inner( + &self, + 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 + .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, &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, + &policy_state, + &self.policy_signer, + )), + ("ImportProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) + } + ("UpdateProviderProfiles", GatewayInterceptorPhase::Validate) => { + Ok(self.validate_update_provider_profiles(&operation, &profile_state.ids)) + } + ("DeleteProviderProfile", GatewayInterceptorPhase::Validate) => { + Ok(validate_delete_provider_profile()) + } + _ => Ok(allow()), + } + } + + fn patch_create_sandbox( + operation: &Value, + 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", + policy_state.policy.clone(), + )?); + } else { + patches.push(json_patch( + "add", + "/spec", + json!({ + "policy": policy_state.policy.clone(), + }), + )?); + } + + add_policy_signature_patches(operation, &mut patches, &policy_state.policy_signature)?; + + let mut result = allow(); + result.patches = patches; + result.log_annotations.insert( + "correlation_id".to_string(), + create_sandbox_correlation_id(operation), + ); + result + .log_annotations + .insert("policy_hash".to_string(), policy_state.policy_hash.clone()); + result.log_annotations.insert( + "policy_signature_kid".to_string(), + policy_state.policy_signature_kid.clone(), + ); + Ok(result) + } + + fn current_profile_state(&self) -> ProviderProfileState { + if let Some(path) = &self.profiles_path { + 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(); + } + 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 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, + managed_profile_ids: &[String], + ) -> InterceptorResult { + let provider_type = provider_type(operation); + if !is_managed_profile_id(managed_profile_ids, provider_type) { + return deny(&format!( + "providers may only use vended provider profiles: {}", + format_id_list(managed_profile_ids) + )); + } + allow() + } + + 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"); + }; + 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 !is_managed_profile_id(managed_profile_ids, id) { + return deny(&format!( + "only managed provider profiles may be imported: {}", + format_id_list(managed_profile_ids) + )); + } + } + allow() + } + + 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 !is_managed_profile_id(managed_profile_ids, target_id) { + return deny(&format!( + "only managed provider profiles may be updated: {}", + format_id_list(managed_profile_ids) + )); + } + 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() + } +} + +#[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) + } + + 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 { + 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 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(), + log_annotations: HashMap::new(), + } +} + +fn deny(reason: &str) -> InterceptorResult { + InterceptorResult { + allowed: false, + reason: reason.to_string(), + status_code: "PERMISSION_DENIED".to_string(), + patches: Vec::new(), + log_annotations: HashMap::new(), + } +} + +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(); + } + 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()); + 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 !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") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn profile_id_from_import_item(item: &Value) -> &str { + item.pointer("/profile/id") + .and_then(Value::as_str) + .unwrap_or_default() +} + +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 { 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 profile_state_from_loaded( + profiles: Vec, + policy_signer: &PolicySigner, +) -> Result { + let ids = loaded_profile_ids(&profiles); + let profiles = profiles + .into_iter() + .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 { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-governance-provider-profiles-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +fn deterministic_profile_hash(profile: &ProviderProfile) -> String { + 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) +} + +fn format_id_list(ids: &[String]) -> String { + ids.join(", ") +} + +fn providers_are_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { + let Some(value) = value else { + return true; + }; + let Value::Array(providers) = value else { + return false; + }; + providers.iter().all(|provider| { + provider + .as_str() + .is_some_and(|provider| is_managed_profile_id(managed_profile_ids, 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 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") +} + +fn normalize_for_struct(value: Value) -> Result { + json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) +} + +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) -> 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 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 { + 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()) + } + } +} + +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() { + "--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)); + } + "--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); + } + "--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] [--policy-watch-interval-ms MS]" + ); + return Ok(()); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + + 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!("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() + .add_service(GatewayInterceptorServer::new(service)) + .serve(listen) + .await?; + Ok(()) +} + +fn default_profiles_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles") +} + +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/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 new file mode 100644 index 000000000..357f55950 --- /dev/null +++ b/proto/gateway_interceptor.proto @@ -0,0 +1,116 @@ +// 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"; +import "openshell.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); + + // 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); +} + +message DescribeRequest {} + +message ProviderProfileSnapshotRequest {} + +enum GatewayInterceptorPhase { + GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; + 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 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 logs. + map log_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. + // Supported values are "fail_closed" and "fail_open". + string failure_policy = 3; + // True when this interceptor implements SnapshotProviderProfiles. + bool provider_profiles = 4; +} + +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 = 2; +} + +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. + // Supported values are "fail_closed" and "fail_open". + 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; +} diff --git a/proto/openshell.proto b/proto/openshell.proto index bf803e864..bd1a70e2d 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. @@ -1096,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. @@ -1226,6 +1230,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 { @@ -1281,6 +1288,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.