From efd8bef835f391cd5b3f7d6ab9d39bf687161f8d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 16 Jun 2026 13:26:29 -0700 Subject: [PATCH] feat(gateway): add operation interceptors Signed-off-by: Drew Newberry --- Cargo.lock | 41 + Cargo.toml | 2 +- architecture/gateway.md | 35 + crates/openshell-cli/Cargo.toml | 1 + crates/openshell-core/src/proto/mod.rs | 13 + crates/openshell-interceptors/Cargo.toml | 35 + crates/openshell-interceptors/src/lib.rs | 1444 +++++++++++++++ crates/openshell-prover/Cargo.toml | 1 + crates/openshell-server/Cargo.toml | 3 + crates/openshell-server/src/compute/mod.rs | 12 +- crates/openshell-server/src/config_file.rs | 23 + crates/openshell-server/src/grpc/policy.rs | 268 ++- crates/openshell-server/src/grpc/provider.rs | 264 ++- crates/openshell-server/src/grpc/sandbox.rs | 283 ++- .../openshell-server/src/grpc/validation.rs | 74 +- crates/openshell-server/src/interceptors.rs | 1634 +++++++++++++++++ crates/openshell-server/src/lib.rs | 21 + docs/reference/gateway-config.mdx | 35 + docs/reference/gateway-interceptors.mdx | 114 ++ .../policy-governance-interceptor/Cargo.toml | 33 + .../policy-governance-interceptor/README.md | 101 + .../policy-governance-interceptor/policy.yaml | 70 + .../policy-governance-interceptor/smoke.sh | 367 ++++ .../policy-governance-interceptor/src/lib.rs | 1194 ++++++++++++ .../policy-governance-interceptor/src/main.rs | 31 + proto/interceptor.proto | 119 ++ 26 files changed, 6133 insertions(+), 85 deletions(-) create mode 100644 crates/openshell-interceptors/Cargo.toml create mode 100644 crates/openshell-interceptors/src/lib.rs create mode 100644 crates/openshell-server/src/interceptors.rs create mode 100644 docs/reference/gateway-interceptors.mdx create mode 100644 examples/policy-governance-interceptor/Cargo.toml create mode 100644 examples/policy-governance-interceptor/README.md create mode 100644 examples/policy-governance-interceptor/policy.yaml create mode 100755 examples/policy-governance-interceptor/smoke.sh create mode 100644 examples/policy-governance-interceptor/src/lib.rs create mode 100644 examples/policy-governance-interceptor/src/main.rs create mode 100644 proto/interceptor.proto diff --git a/Cargo.lock b/Cargo.lock index 005a1c54b..783eea729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3498,6 +3498,26 @@ dependencies = [ "zstd", ] +[[package]] +name = "openshell-interceptors" +version = "0.0.0" +dependencies = [ + "hyper-util", + "json-patch", + "metrics", + "openshell-core", + "prost-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", + "tonic", + "tower 0.5.3", + "tracing", + "url", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -3596,6 +3616,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "base64 0.22.1", "bytes", "clap", "futures", @@ -3621,6 +3642,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-podman", + "openshell-interceptors", "openshell-ocsf", "openshell-policy", "openshell-prover", @@ -4121,6 +4143,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "policy-governance-interceptor" +version = "0.0.0" +dependencies = [ + "json-patch", + "jsonwebtoken 9.3.1", + "openshell-core", + "openshell-interceptors", + "openshell-policy", + "prost-types", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tonic", + "tracing", + "tracing-subscriber", +] + [[package]] name = "polling" version = "3.11.0" diff --git a/Cargo.toml b/Cargo.toml index 86025646a..04a2c6d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ [workspace] resolver = "2" -members = ["crates/*"] +members = ["crates/*", "examples/policy-governance-interceptor"] [workspace.package] version = "0.0.0" diff --git a/architecture/gateway.md b/architecture/gateway.md index 7afec0767..a6c5d9593 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -107,6 +107,31 @@ Domain objects use shared metadata: stable server-generated IDs, human-readable names, creation timestamps, and labels. Crate-level details live in `crates/openshell-core/README.md`. +## Interceptors + +Gateway interceptors are an optional operation-review boundary for deployments +that need organization-specific controls outside the gateway binary. They are +disabled unless configured in gateway TOML. At startup, the gateway connects to +each configured gRPC or Unix-socket service, calls `Describe`, validates the +returned manifest, applies any local narrowing overrides, and builds a +deterministically ordered review plan. Invalid manifests or unreachable +interceptors fail gateway startup. + +Handlers call the interceptor runtime only after authentication and basic +request validation. The covered write paths are sandbox create and provider +attach/detach, provider and provider-profile writes, policy/config updates, and +the driver-facing sandbox create shape before compute-driver validation. The +runtime supports `pre_request`, `modify_object`, `validate_object`, +`validate_driver`, and `post_commit` phases. Modification phases are available +only where the gateway owns explicit protobuf-to-JSON round-trip adapters; +validation phases may deny but cannot patch. + +Provider credentials are treated as secret-bearing fields. Review payloads +redact credential values, and patches that read or write provider credential +paths are rejected before they can modify persisted state. Interceptor denials +and security-relevant failures are visible through structured tracing, gateway +OCSF finding events, and metrics. + ## Persistence The gateway persistence layer is a protobuf object store. Domain services store @@ -150,6 +175,11 @@ This keeps the gateway data model portable across storage backends and leaves room for future stores that can provide the same object, label, version, and scope semantics. +User-supplied labels are validated with Kubernetes label-value limits at API +boundaries. Trusted gateway extensions, such as interceptors, may attach longer +stored metadata values when those values are not projected to compute-platform +labels. + The SQLite adapter tightens the on-disk database file to mode `0o600` on every connect so that provider API keys, SSH session tokens, and sandbox metadata are not readable by other local users on shared hosts. The same restriction is @@ -422,6 +452,11 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +Gateway interceptors are configured under +`[[openshell.gateway.interceptors]]`. They do not inherit into driver tables. +Interceptor service manifests are validated at startup before the gateway +accepts traffic. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 577fb73b9..c8e581866 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -86,6 +86,7 @@ workspace = true [features] bundled-z3 = ["openshell-prover/bundled-z3"] +gh-release-z3 = ["openshell-prover/gh-release-z3"] dev-settings = ["openshell-core/dev-settings"] [dev-dependencies] diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e..3c75a0661 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -79,6 +79,19 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod interceptor { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/openshell.interceptor.v1.rs")); + } +} + pub use datamodel::v1::*; pub use inference::v1::*; pub use openshell::*; diff --git a/crates/openshell-interceptors/Cargo.toml b/crates/openshell-interceptors/Cargo.toml new file mode 100644 index 000000000..a1f021247 --- /dev/null +++ b/crates/openshell-interceptors/Cargo.toml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-interceptors" +description = "Gateway interceptor config, planning, transport, and review runtime" +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 } + +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +prost-types = { workspace = true } +hyper-util = { workspace = true, features = ["tokio"] } +tower = { workspace = true } + +metrics = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +json-patch = "1.4" + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/crates/openshell-interceptors/src/lib.rs b/crates/openshell-interceptors/src/lib.rs new file mode 100644 index 000000000..93e0d804c --- /dev/null +++ b/crates/openshell-interceptors/src/lib.rs @@ -0,0 +1,1444 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway operation interceptor config, planning, transport, and review runtime. + +#![allow(clippy::result_large_err)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hyper_util::rt::TokioIo; +use metrics::{counter, histogram}; +use openshell_core::proto::interceptor::v1::{ + InterceptorBinding, InterceptorDecision, InterceptorDescribeRequest, InterceptorManifest, + InterceptorPrincipal, InterceptorRequestContext, InterceptorReview, JsonPatch, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost_types::{ListValue, Struct, Value as ProtoValue, value::Kind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use tokio::sync::Mutex; +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use tonic::{Request, Status}; +use tower::service_fn; +use tracing::{debug, info, warn}; +use url::Url; + +pub const API_VERSION: &str = "gateway.interceptor.openshell.dev/v1"; +pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +pub const DEFAULT_MAX_PATCH_COUNT: usize = 32; +pub const DEFAULT_MAX_DECODING_MESSAGE_SIZE: usize = 1024 * 1024; + +pub const PHASE_PRE_REQUEST: &str = "pre_request"; +pub const PHASE_MODIFY_OBJECT: &str = "modify_object"; +pub const PHASE_VALIDATE_OBJECT: &str = "validate_object"; +pub const PHASE_VALIDATE_DRIVER: &str = "validate_driver"; +pub const PHASE_POST_COMMIT: &str = "post_commit"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailurePolicy { + FailClosed, + FailOpen, + Ignore, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + Self::Ignore => "ignore", + } + } +} + +impl fmt::Display for FailurePolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for FailurePolicy { + type Err = InterceptorConfigError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" => Err(InterceptorConfigError::InvalidFailurePolicy { + value: value.to_string(), + }), + "fail_closed" => Ok(Self::FailClosed), + "fail_open" => Ok(Self::FailOpen), + "ignore" => Ok(Self::Ignore), + other => Err(InterceptorConfigError::InvalidFailurePolicy { + value: other.to_string(), + }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterceptorConfig { + pub name: String, + pub endpoint: String, + #[serde(default)] + pub order: i32, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub failure_policy: Option, + #[serde(default = "empty_toml_table")] + pub config: toml::Value, + #[serde(default)] + pub overrides: Vec, +} + +impl Default for InterceptorConfig { + fn default() -> Self { + Self { + name: String::new(), + endpoint: String::new(), + order: 0, + timeout: None, + failure_policy: None, + config: empty_toml_table(), + overrides: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BindingOverrideConfig { + pub binding: String, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub order: Option, + #[serde(default)] + pub failure_policy: Option, + #[serde(default, rename = "match")] + pub match_: BindingMatchConfig, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BindingMatchConfig { + #[serde(default)] + pub phases: Option>, + #[serde(default)] + pub resources: Option>, + #[serde(default)] + pub operations: Option>, + #[serde(default)] + pub principal_kinds: Option>, + #[serde(default)] + pub principal_groups: Option>, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub compute_drivers: Option>, +} + +fn empty_toml_table() -> toml::Value { + toml::Value::Table(toml::Table::new()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InterceptorEndpoint { + Grpc { authority: String, tls: bool }, + Unix { path: PathBuf }, +} + +impl InterceptorEndpoint { + pub fn parse(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|source| InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: source.to_string(), + })?; + match url.scheme() { + "grpc" | "grpcs" => { + let host = + url.host_str() + .ok_or_else(|| InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: "TCP endpoint requires a host".to_string(), + })?; + let port = url + .port() + .ok_or_else(|| InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: "TCP endpoint requires an explicit port".to_string(), + })?; + if !url.path().trim_matches('/').is_empty() { + return Err(InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: "TCP endpoint must not include a path".to_string(), + }); + } + if url.scheme() == "grpc" && !is_loopback_host(host) { + warn!( + endpoint = %raw, + "plaintext grpc interceptor endpoint is not loopback; use grpcs:// for remote services" + ); + } + Ok(Self::Grpc { + authority: format!("{host}:{port}"), + tls: url.scheme() == "grpcs", + }) + } + "unix" => { + let path = PathBuf::from(url.path()); + if path.as_os_str().is_empty() || !path.is_absolute() { + return Err(InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: "unix endpoint requires an absolute socket path".to_string(), + }); + } + Ok(Self::Unix { path }) + } + other => Err(InterceptorConfigError::InvalidEndpoint { + endpoint: raw.to_string(), + message: format!("unsupported scheme '{other}'"), + }), + } + } +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +#[derive(Debug, thiserror::Error)] +pub enum InterceptorConfigError { + #[error("interceptor name is required")] + MissingName, + #[error("interceptor '{name}' endpoint is required")] + MissingEndpoint { name: String }, + #[error("duplicate interceptor name '{name}'")] + DuplicateName { name: String }, + #[error("invalid interceptor endpoint '{endpoint}': {message}")] + InvalidEndpoint { endpoint: String, message: String }, + #[error("invalid interceptor timeout '{value}'")] + InvalidTimeout { value: String }, + #[error("invalid interceptor failure policy '{value}'")] + InvalidFailurePolicy { value: String }, + #[error("interceptor '{interceptor}' describe failed: {message}")] + DescribeFailed { + interceptor: String, + message: String, + }, + #[error("interceptor '{interceptor}' manifest has unsupported api_version '{api_version}'")] + UnsupportedApiVersion { + interceptor: String, + api_version: String, + }, + #[error("interceptor '{interceptor}' manifest contains duplicate binding '{binding}'")] + DuplicateBinding { + interceptor: String, + binding: String, + }, + #[error("interceptor '{interceptor}' binding id is required")] + MissingBindingId { interceptor: String }, + #[error("interceptor '{interceptor}' binding '{binding}' must declare at least one phase")] + MissingPhases { + interceptor: String, + binding: String, + }, + #[error("interceptor '{interceptor}' binding '{binding}' uses unknown phase '{phase}'")] + UnknownPhase { + interceptor: String, + binding: String, + phase: String, + }, + #[error( + "interceptor '{interceptor}' binding '{binding}' declares modifies=true outside modification phases" + )] + InvalidModifies { + interceptor: String, + binding: String, + }, + #[error( + "interceptor '{interceptor}' binding '{binding}' uses failure_policy=ignore outside post_commit" + )] + InvalidIgnorePolicy { + interceptor: String, + binding: String, + }, + #[error("interceptor '{interceptor}' override references unknown binding '{binding}'")] + UnknownOverrideBinding { + interceptor: String, + binding: String, + }, + #[error( + "interceptor '{interceptor}' override for binding '{binding}' expands {field} beyond the service manifest" + )] + OverrideExpands { + interceptor: String, + binding: String, + field: &'static str, + }, + #[error("interceptor '{interceptor}' config must be a TOML table")] + ConfigMustBeTable { interceptor: String }, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReviewError { + #[error("interceptor denied operation: {reason}")] + Denied { + interceptor: String, + binding: String, + phase: String, + resource: String, + operation: String, + status_code: tonic::Code, + reason: String, + }, + #[error("{0}")] + Failed(Status), +} + +impl ReviewError { + #[must_use] + pub fn into_status(self) -> Status { + match self { + Self::Denied { + status_code, + reason, + .. + } => Status::new(status_code, reason), + Self::Failed(status) => status, + } + } +} + +#[derive(Debug, Clone)] +pub struct ReviewInput { + pub phase: String, + pub resource: String, + pub operation: String, + pub principal: InterceptorPrincipal, + pub context: InterceptorRequestContext, + pub object: JsonValue, + pub old_object: Option, + pub request: Option, + pub modification_allowed: bool, +} + +#[derive(Debug, Clone)] +pub struct ReviewOutcome { + pub object: JsonValue, + pub applied_patches: Vec, + pub warnings: Vec, + pub audit_annotations: BTreeMap, +} + +struct InterceptorServiceRuntime { + timeout: Duration, + client: Mutex>, +} + +pub struct InterceptorRuntime { + services: BTreeMap>, + bindings: Vec, + max_patch_count: usize, +} + +impl fmt::Debug for InterceptorRuntime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InterceptorRuntime") + .field("services", &self.services.keys().collect::>()) + .field("bindings", &self.bindings) + .field("max_patch_count", &self.max_patch_count) + .finish() + } +} + +impl Default for InterceptorRuntime { + fn default() -> Self { + Self::empty() + } +} + +impl InterceptorRuntime { + #[must_use] + pub fn empty() -> Self { + Self { + services: BTreeMap::new(), + bindings: Vec::new(), + max_patch_count: DEFAULT_MAX_PATCH_COUNT, + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.bindings.is_empty() + } + + pub async fn from_config( + configs: &[InterceptorConfig], + ) -> Result { + if configs.is_empty() { + return Ok(Self::empty()); + } + let mut names = BTreeSet::new(); + for config in configs { + let name = config.name.trim(); + if name.is_empty() { + return Err(InterceptorConfigError::MissingName); + } + if !names.insert(name.to_string()) { + return Err(InterceptorConfigError::DuplicateName { + name: name.to_string(), + }); + } + if config.endpoint.trim().is_empty() { + return Err(InterceptorConfigError::MissingEndpoint { + name: name.to_string(), + }); + } + } + + let mut services = BTreeMap::new(); + let mut bindings = Vec::new(); + for config in configs { + let service = build_service_runtime(config).await?; + let mut client = service.client.lock().await; + let manifest = describe(config, &mut client).await?; + drop(client); + validate_manifest(config, &manifest)?; + bindings.extend(plan_bindings(config, &manifest)?); + services.insert(config.name.clone(), Arc::new(service)); + } + + bindings.sort_by(|left, right| { + left.service_order + .cmp(&right.service_order) + .then_with(|| left.binding_order.cmp(&right.binding_order)) + .then_with(|| left.interceptor_name.cmp(&right.interceptor_name)) + .then_with(|| left.binding_id.cmp(&right.binding_id)) + }); + + Ok(Self { + services, + bindings, + max_patch_count: DEFAULT_MAX_PATCH_COUNT, + }) + } + + pub async fn review(&self, input: ReviewInput) -> Result { + if self.bindings.is_empty() { + return Ok(ReviewOutcome { + object: input.object, + applied_patches: Vec::new(), + warnings: Vec::new(), + audit_annotations: BTreeMap::new(), + }); + } + + let matching = self + .bindings + .iter() + .filter(|binding| binding.matches(&input)) + .collect::>(); + let mut object = input.object.clone(); + let mut applied_patches = Vec::new(); + let mut warnings = Vec::new(); + let mut audit_annotations = BTreeMap::new(); + + for binding in matching { + let Some(service) = self.services.get(&binding.interceptor_name) else { + return Err(ReviewError::Failed(Status::internal(format!( + "interceptor '{}' runtime is missing", + binding.interceptor_name + )))); + }; + + let review = InterceptorReview { + api_version: API_VERSION.to_string(), + interceptor_name: binding.interceptor_name.clone(), + binding_id: binding.binding_id.clone(), + phase: input.phase.clone(), + resource: input.resource.clone(), + operation: input.operation.clone(), + principal: Some(input.principal.clone()), + context: Some(input.context.clone()), + object: Some(json_to_struct(&object).map_err(|err| { + ReviewError::Failed(Status::internal(format!( + "build interceptor object payload failed: {err}" + ))) + })?), + old_object: input + .old_object + .as_ref() + .map(json_to_struct) + .transpose() + .map_err(|err| { + ReviewError::Failed(Status::internal(format!( + "build interceptor old_object payload failed: {err}" + ))) + })?, + request: input + .request + .as_ref() + .map(json_to_struct) + .transpose() + .map_err(|err| { + ReviewError::Failed(Status::internal(format!( + "build interceptor request payload failed: {err}" + ))) + })?, + }; + + let decision = match call_review(service, review, binding).await { + Ok(decision) => decision, + Err(err) => { + handle_interceptor_failure(binding, err)?; + continue; + } + }; + + log_decision(binding, &input, &decision); + metrics_decision(binding, &input, &decision); + + if !decision.allowed { + let reason = if decision.reason.trim().is_empty() { + "interceptor denied operation".to_string() + } else { + decision.reason.clone() + }; + return Err(ReviewError::Denied { + interceptor: binding.interceptor_name.clone(), + binding: binding.binding_id.clone(), + phase: input.phase.clone(), + resource: input.resource.clone(), + operation: input.operation.clone(), + status_code: status_code(&decision.status_code), + reason, + }); + } + + warnings.extend(decision.warnings); + audit_annotations.extend(decision.audit_annotations); + + if decision.patches.is_empty() { + continue; + } + if !input.modification_allowed { + let err = Status::internal(format!( + "interceptor '{}' binding '{}' returned patches during non-modification phase '{}'", + binding.interceptor_name, binding.binding_id, input.phase + )); + handle_interceptor_failure(binding, err)?; + continue; + } + if decision.patches.len() > self.max_patch_count { + let err = Status::resource_exhausted(format!( + "interceptor '{}' binding '{}' returned {} patches; maximum is {}", + binding.interceptor_name, + binding.binding_id, + decision.patches.len(), + self.max_patch_count + )); + handle_interceptor_failure(binding, err)?; + continue; + } + match apply_proto_patches(&mut object, &decision.patches) { + Ok(()) => applied_patches.extend(decision.patches), + Err(err) => { + let status = Status::invalid_argument(format!( + "interceptor '{}' binding '{}' returned invalid patches: {err}", + binding.interceptor_name, binding.binding_id + )); + handle_interceptor_failure(binding, status)?; + } + } + } + + Ok(ReviewOutcome { + object, + applied_patches, + warnings, + audit_annotations, + }) + } +} + +async fn build_service_runtime( + config: &InterceptorConfig, +) -> Result { + let endpoint = InterceptorEndpoint::parse(&config.endpoint)?; + let timeout = config + .timeout + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or(DEFAULT_TIMEOUT); + let channel = connect_endpoint(&endpoint).await.map_err(|message| { + InterceptorConfigError::DescribeFailed { + interceptor: config.name.clone(), + message, + } + })?; + let client = GatewayInterceptorClient::new(channel) + .max_decoding_message_size(DEFAULT_MAX_DECODING_MESSAGE_SIZE); + Ok(InterceptorServiceRuntime { + timeout, + client: Mutex::new(client), + }) +} + +async fn describe( + config: &InterceptorConfig, + client: &mut GatewayInterceptorClient, +) -> Result { + let request = InterceptorDescribeRequest { + api_version: API_VERSION.to_string(), + interceptor_name: config.name.clone(), + config: Some(toml_value_to_struct(&config.config).map_err(|_| { + InterceptorConfigError::ConfigMustBeTable { + interceptor: config.name.clone(), + } + })?), + }; + let timeout = config + .timeout + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or(DEFAULT_TIMEOUT); + let response = tokio::time::timeout(timeout, client.describe(Request::new(request))) + .await + .map_err(|_| InterceptorConfigError::DescribeFailed { + interceptor: config.name.clone(), + message: "deadline exceeded".to_string(), + })? + .map_err(|status| InterceptorConfigError::DescribeFailed { + interceptor: config.name.clone(), + message: status.to_string(), + })?; + Ok(response.into_inner()) +} + +async fn connect_endpoint(endpoint: &InterceptorEndpoint) -> Result { + match endpoint { + InterceptorEndpoint::Grpc { authority, tls } => { + let scheme = if *tls { "https" } else { "http" }; + let mut endpoint = Endpoint::from_shared(format!("{scheme}://{authority}")) + .map_err(|err| err.to_string())?; + if *tls { + endpoint = endpoint + .tls_config(ClientTlsConfig::new().with_enabled_roots()) + .map_err(|err| err.to_string())?; + } + endpoint.connect().await.map_err(|err| err.to_string()) + } + InterceptorEndpoint::Unix { path } => connect_unix(path).await, + } +} + +#[cfg(unix)] +async fn connect_unix(path: &std::path::Path) -> Result { + use tokio::net::UnixStream; + + let socket_path = path.to_path_buf(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: tonic::transport::Uri| { + let socket_path = socket_path.clone(); + async move { UnixStream::connect(socket_path).await.map(TokioIo::new) } + })) + .await + .map_err(|err| err.to_string()) +} + +#[cfg(not(unix))] +async fn connect_unix(path: &std::path::Path) -> Result { + Err(format!( + "unix interceptor endpoint '{}' is not supported on this platform", + path.display() + )) +} + +#[derive(Debug, Clone)] +struct RuntimeBinding { + interceptor_name: String, + binding_id: String, + service_order: i32, + binding_order: i32, + phases: Vec, + resources: Vec, + operations: Vec, + failure_policy: FailurePolicy, + selector: BindingSelector, +} + +#[derive(Debug, Clone, Default)] +struct BindingSelector { + principal_kinds: Vec, + principal_groups: Vec, + labels: BTreeMap, + compute_drivers: Vec, +} + +impl RuntimeBinding { + fn matches(&self, input: &ReviewInput) -> bool { + matches_string(&self.phases, &input.phase) + && matches_string(&self.resources, &input.resource) + && matches_string(&self.operations, &input.operation) + && matches_string(&self.selector.principal_kinds, &input.principal.kind) + && matches_groups(&self.selector.principal_groups, &input.principal.groups) + && matches_string( + &self.selector.compute_drivers, + &input.context.compute_driver, + ) + && matches_labels(&self.selector.labels, &input.context.labels) + } +} + +fn matches_string(selector: &[String], value: &str) -> bool { + selector.is_empty() || selector.iter().any(|item| item == value) +} + +fn matches_groups(selector: &[String], groups: &[String]) -> bool { + selector.is_empty() + || selector + .iter() + .any(|selected| groups.iter().any(|group| group == selected)) +} + +fn matches_labels( + selector: &BTreeMap, + labels: &std::collections::HashMap, +) -> bool { + selector + .iter() + .all(|(key, value)| labels.get(key).is_some_and(|actual| actual == value)) +} + +fn validate_manifest( + config: &InterceptorConfig, + manifest: &InterceptorManifest, +) -> Result<(), InterceptorConfigError> { + if !manifest.api_version.is_empty() && manifest.api_version != API_VERSION { + return Err(InterceptorConfigError::UnsupportedApiVersion { + interceptor: config.name.clone(), + api_version: manifest.api_version.clone(), + }); + } + + let mut ids = BTreeSet::new(); + for binding in &manifest.bindings { + if binding.id.trim().is_empty() { + return Err(InterceptorConfigError::MissingBindingId { + interceptor: config.name.clone(), + }); + } + if !ids.insert(binding.id.clone()) { + return Err(InterceptorConfigError::DuplicateBinding { + interceptor: config.name.clone(), + binding: binding.id.clone(), + }); + } + if binding.phases.is_empty() { + return Err(InterceptorConfigError::MissingPhases { + interceptor: config.name.clone(), + binding: binding.id.clone(), + }); + } + for phase in &binding.phases { + if !is_known_phase(phase) { + return Err(InterceptorConfigError::UnknownPhase { + interceptor: config.name.clone(), + binding: binding.id.clone(), + phase: phase.clone(), + }); + } + } + if binding.modifies + && binding + .phases + .iter() + .any(|phase| !is_modification_phase(phase)) + { + return Err(InterceptorConfigError::InvalidModifies { + interceptor: config.name.clone(), + binding: binding.id.clone(), + }); + } + } + + for override_config in &config.overrides { + if !ids.contains(&override_config.binding) { + return Err(InterceptorConfigError::UnknownOverrideBinding { + interceptor: config.name.clone(), + binding: override_config.binding.clone(), + }); + } + } + + Ok(()) +} + +fn plan_bindings( + config: &InterceptorConfig, + manifest: &InterceptorManifest, +) -> Result, InterceptorConfigError> { + let mut out = Vec::new(); + for binding in &manifest.bindings { + let override_config = config + .overrides + .iter() + .find(|override_config| override_config.binding == binding.id); + if override_config.and_then(|o| o.enabled) == Some(false) { + continue; + } + let phases = narrow_list( + config, + binding, + "phases", + &binding.phases, + override_config.and_then(|o| o.match_.phases.as_ref()), + )?; + let resources = narrow_list( + config, + binding, + "resources", + &binding.resources, + override_config.and_then(|o| o.match_.resources.as_ref()), + )?; + let operations = narrow_list( + config, + binding, + "operations", + &binding.operations, + override_config.and_then(|o| o.match_.operations.as_ref()), + )?; + let selector = binding.selector.clone().unwrap_or_default(); + let principal_kinds = narrow_list( + config, + binding, + "principal_kinds", + &selector.principal_kinds, + override_config.and_then(|o| o.match_.principal_kinds.as_ref()), + )?; + let principal_groups = narrow_list( + config, + binding, + "principal_groups", + &selector.principal_groups, + override_config.and_then(|o| o.match_.principal_groups.as_ref()), + )?; + let compute_drivers = narrow_list( + config, + binding, + "compute_drivers", + &selector.compute_drivers, + override_config.and_then(|o| o.match_.compute_drivers.as_ref()), + )?; + let labels = narrow_labels( + config, + binding, + &selector.labels, + override_config.and_then(|o| o.match_.labels.as_ref()), + )?; + let manifest_failure_policy = parse_manifest_failure_policy(binding).transpose()?; + let failure_policy = override_config + .and_then(|o| o.failure_policy) + .or(config.failure_policy) + .or(manifest_failure_policy) + .unwrap_or_else(|| default_failure_policy(binding, &phases)); + if failure_policy == FailurePolicy::Ignore + && phases.iter().any(|phase| phase != PHASE_POST_COMMIT) + { + return Err(InterceptorConfigError::InvalidIgnorePolicy { + interceptor: config.name.clone(), + binding: binding.id.clone(), + }); + } + let binding_order = override_config + .and_then(|o| o.order) + .unwrap_or(binding.order); + out.push(RuntimeBinding { + interceptor_name: config.name.clone(), + binding_id: binding.id.clone(), + service_order: config.order, + binding_order, + phases, + resources, + operations, + failure_policy, + selector: BindingSelector { + principal_kinds, + principal_groups, + labels, + compute_drivers, + }, + }); + } + Ok(out) +} + +fn narrow_list( + config: &InterceptorConfig, + binding: &InterceptorBinding, + field: &'static str, + declared: &[String], + override_values: Option<&Vec>, +) -> Result, InterceptorConfigError> { + let Some(values) = override_values else { + return Ok(declared.to_vec()); + }; + if !declared.is_empty() && values.iter().any(|value| !declared.contains(value)) { + return Err(InterceptorConfigError::OverrideExpands { + interceptor: config.name.clone(), + binding: binding.id.clone(), + field, + }); + } + Ok(values.clone()) +} + +fn narrow_labels( + config: &InterceptorConfig, + binding: &InterceptorBinding, + declared: &std::collections::HashMap, + override_values: Option<&BTreeMap>, +) -> Result, InterceptorConfigError> { + let declared = declared + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let Some(values) = override_values else { + return Ok(declared); + }; + for (key, value) in values { + if let Some(declared_value) = declared.get(key) + && declared_value != value + { + return Err(InterceptorConfigError::OverrideExpands { + interceptor: config.name.clone(), + binding: binding.id.clone(), + field: "labels", + }); + } + } + let mut narrowed = declared; + narrowed.extend(values.clone()); + Ok(narrowed) +} + +fn parse_manifest_failure_policy( + binding: &InterceptorBinding, +) -> Option> { + (!binding.default_failure_policy.trim().is_empty()) + .then(|| binding.default_failure_policy.parse()) +} + +fn default_failure_policy(_binding: &InterceptorBinding, phases: &[String]) -> FailurePolicy { + if phases.iter().all(|phase| phase == PHASE_POST_COMMIT) { + FailurePolicy::Ignore + } else { + FailurePolicy::FailClosed + } +} + +fn is_known_phase(phase: &str) -> bool { + matches!( + phase, + PHASE_PRE_REQUEST + | PHASE_MODIFY_OBJECT + | PHASE_VALIDATE_OBJECT + | PHASE_VALIDATE_DRIVER + | PHASE_POST_COMMIT + ) +} + +fn is_modification_phase(phase: &str) -> bool { + matches!(phase, PHASE_PRE_REQUEST | PHASE_MODIFY_OBJECT) +} + +async fn call_review( + service: &InterceptorServiceRuntime, + review: InterceptorReview, + binding: &RuntimeBinding, +) -> Result { + let started = Instant::now(); + let mut client = service.client.lock().await; + let response = tokio::time::timeout(service.timeout, client.review(Request::new(review))) + .await + .map_err(|_| Status::deadline_exceeded("interceptor review deadline exceeded"))? + .map_err(|status| { + Status::new( + status.code(), + format!("interceptor review failed: {}", status.message()), + ) + }); + let elapsed = started.elapsed().as_secs_f64(); + let code = response.as_ref().map_or_else( + |status| status.code().to_string(), + |_| tonic::Code::Ok.to_string(), + ); + counter!( + "openshell_gateway_interceptor_reviews_total", + "interceptor" => binding.interceptor_name.clone(), + "binding" => binding.binding_id.clone(), + "code" => code.clone(), + ) + .increment(1); + histogram!( + "openshell_gateway_interceptor_review_latency_seconds", + "interceptor" => binding.interceptor_name.clone(), + "binding" => binding.binding_id.clone(), + "code" => code, + ) + .record(elapsed); + response.map(tonic::Response::into_inner) +} + +fn handle_interceptor_failure(binding: &RuntimeBinding, status: Status) -> Result<(), ReviewError> { + counter!( + "openshell_gateway_interceptor_failures_total", + "interceptor" => binding.interceptor_name.clone(), + "binding" => binding.binding_id.clone(), + "failure_policy" => binding.failure_policy.as_str(), + "code" => status.code().to_string(), + ) + .increment(1); + match binding.failure_policy { + FailurePolicy::FailClosed => Err(ReviewError::Failed(status)), + FailurePolicy::FailOpen => { + warn!( + interceptor = %binding.interceptor_name, + binding = %binding.binding_id, + failure_policy = %binding.failure_policy, + error = %status, + "interceptor failure ignored by fail_open policy" + ); + Ok(()) + } + FailurePolicy::Ignore => { + debug!( + interceptor = %binding.interceptor_name, + binding = %binding.binding_id, + failure_policy = %binding.failure_policy, + error = %status, + "post_commit interceptor failure ignored" + ); + Ok(()) + } + } +} + +fn log_decision(binding: &RuntimeBinding, input: &ReviewInput, decision: &InterceptorDecision) { + info!( + interceptor = %binding.interceptor_name, + binding = %binding.binding_id, + phase = %input.phase, + resource = %input.resource, + operation = %input.operation, + principal_subject = %input.principal.subject, + decision = if decision.allowed { "allow" } else { "deny" }, + reason = %decision.reason, + failure_policy = %binding.failure_policy, + patch_count = decision.patches.len(), + audit_annotations = ?decision.audit_annotations, + warnings = ?decision.warnings, + "gateway interceptor decision" + ); +} + +fn metrics_decision(binding: &RuntimeBinding, input: &ReviewInput, decision: &InterceptorDecision) { + counter!( + "openshell_gateway_interceptor_decisions_total", + "interceptor" => binding.interceptor_name.clone(), + "binding" => binding.binding_id.clone(), + "phase" => input.phase.clone(), + "resource" => input.resource.clone(), + "operation" => input.operation.clone(), + "decision" => if decision.allowed { "allow" } else { "deny" }, + ) + .increment(1); +} + +fn status_code(value: &str) -> tonic::Code { + let value = value.trim().to_ascii_lowercase(); + if value.is_empty() { + return tonic::Code::PermissionDenied; + } + match value.as_str() { + "cancelled" | "canceled" => tonic::Code::Cancelled, + "unknown" => tonic::Code::Unknown, + "invalid_argument" => tonic::Code::InvalidArgument, + "deadline_exceeded" => tonic::Code::DeadlineExceeded, + "not_found" => tonic::Code::NotFound, + "already_exists" => tonic::Code::AlreadyExists, + "resource_exhausted" => tonic::Code::ResourceExhausted, + "failed_precondition" => tonic::Code::FailedPrecondition, + "aborted" => tonic::Code::Aborted, + "out_of_range" => tonic::Code::OutOfRange, + "unimplemented" => tonic::Code::Unimplemented, + "internal" => tonic::Code::Internal, + "unavailable" => tonic::Code::Unavailable, + "data_loss" => tonic::Code::DataLoss, + "unauthenticated" => tonic::Code::Unauthenticated, + _ => tonic::Code::PermissionDenied, + } +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.trim(); + let split_at = value + .find(|ch: char| !ch.is_ascii_digit()) + .unwrap_or(value.len()); + let (number, unit) = value.split_at(split_at); + if number.is_empty() || unit.is_empty() { + return Err(InterceptorConfigError::InvalidTimeout { + value: value.to_string(), + }); + } + let number = number + .parse::() + .map_err(|_| InterceptorConfigError::InvalidTimeout { + value: value.to_string(), + })?; + match unit { + "ms" => Ok(Duration::from_millis(number)), + "s" => Ok(Duration::from_secs(number)), + "m" => Ok(Duration::from_secs(number.saturating_mul(60))), + _ => Err(InterceptorConfigError::InvalidTimeout { + value: value.to_string(), + }), + } +} + +pub fn apply_proto_patches(object: &mut JsonValue, patches: &[JsonPatch]) -> Result<(), String> { + let patch = proto_patches_to_json_patch(patches)?; + json_patch::patch(object, &patch).map_err(|err| err.to_string()) +} + +fn proto_patches_to_json_patch(patches: &[JsonPatch]) -> Result { + let values = patches + .iter() + .map(|patch| { + let mut object = JsonMap::new(); + object.insert("op".to_string(), JsonValue::String(patch.op.clone())); + object.insert("path".to_string(), JsonValue::String(patch.path.clone())); + if !patch.from.is_empty() { + object.insert("from".to_string(), JsonValue::String(patch.from.clone())); + } + if let Some(value) = patch.value.as_ref() { + object.insert("value".to_string(), proto_value_to_json(value)); + } + JsonValue::Object(object) + }) + .collect::>(); + serde_json::from_value(JsonValue::Array(values)).map_err(|err| err.to_string()) +} + +pub fn json_to_struct(value: &JsonValue) -> Result { + match value { + JsonValue::Object(object) => Ok(Struct { + fields: object + .iter() + .map(|(key, value)| Ok((key.clone(), json_to_proto_value(value)?))) + .collect::>()?, + }), + _ => Err("protobuf Struct payload must be a JSON object".to_string()), + } +} + +pub fn struct_to_json(value: &Struct) -> JsonValue { + JsonValue::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect(), + ) +} + +pub fn json_to_proto_value(value: &JsonValue) -> Result { + let kind = match value { + JsonValue::Null => Kind::NullValue(0), + JsonValue::Bool(value) => Kind::BoolValue(*value), + JsonValue::Number(value) => Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| "JSON number is not representable as f64".to_string())?, + ), + JsonValue::String(value) => Kind::StringValue(value.clone()), + JsonValue::Array(values) => Kind::ListValue(ListValue { + values: values + .iter() + .map(json_to_proto_value) + .collect::>()?, + }), + JsonValue::Object(values) => { + Kind::StructValue(json_to_struct(&JsonValue::Object(values.clone()))?) + } + }; + Ok(ProtoValue { kind: Some(kind) }) +} + +pub fn proto_value_to_json(value: &ProtoValue) -> JsonValue { + match value.kind.as_ref() { + Some(Kind::NullValue(_)) | None => JsonValue::Null, + Some(Kind::NumberValue(value)) => { + serde_json::Number::from_f64(*value).map_or(JsonValue::Null, JsonValue::Number) + } + Some(Kind::StringValue(value)) => JsonValue::String(value.clone()), + Some(Kind::BoolValue(value)) => JsonValue::Bool(*value), + Some(Kind::StructValue(value)) => struct_to_json(value), + Some(Kind::ListValue(value)) => { + JsonValue::Array(value.values.iter().map(proto_value_to_json).collect()) + } + } +} + +pub fn toml_value_to_struct(value: &toml::Value) -> Result { + json_to_struct(&toml_to_json(value)) +} + +fn toml_to_json(value: &toml::Value) -> JsonValue { + match value { + toml::Value::String(value) => JsonValue::String(value.clone()), + toml::Value::Integer(value) => serde_json::json!(value), + toml::Value::Float(value) => { + serde_json::Number::from_f64(*value).map_or(JsonValue::Null, JsonValue::Number) + } + toml::Value::Boolean(value) => JsonValue::Bool(*value), + toml::Value::Datetime(value) => JsonValue::String(value.to_string()), + toml::Value::Array(values) => JsonValue::Array(values.iter().map(toml_to_json).collect()), + toml::Value::Table(values) => JsonValue::Object( + values + .iter() + .map(|(key, value)| (key.clone(), toml_to_json(value))) + .collect(), + ), + } +} + +#[cfg(test)] +pub mod test_helpers { + use super::*; + use openshell_core::proto::interceptor::v1::InterceptorSelector; + + #[must_use] + pub fn allow_manifest(binding: InterceptorBinding) -> InterceptorManifest { + InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![binding], + } + } + + #[must_use] + pub fn binding(id: &str, phase: &str, resource: &str, operation: &str) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + phases: vec![phase.to_string()], + resources: vec![resource.to_string()], + operations: vec![operation.to_string()], + order: 0, + modifies: matches!(phase, PHASE_PRE_REQUEST | PHASE_MODIFY_OBJECT), + default_failure_policy: String::new(), + selector: Some(InterceptorSelector::default()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::interceptor::v1::InterceptorSelector; + + #[test] + fn parses_endpoints() { + assert!(matches!( + InterceptorEndpoint::parse("grpc://127.0.0.1:9000").unwrap(), + InterceptorEndpoint::Grpc { tls: false, .. } + )); + assert!(matches!( + InterceptorEndpoint::parse("grpcs://policy.example.com:443").unwrap(), + InterceptorEndpoint::Grpc { tls: true, .. } + )); + assert!(matches!( + InterceptorEndpoint::parse("unix:///tmp/interceptor.sock").unwrap(), + InterceptorEndpoint::Unix { .. } + )); + assert!(InterceptorEndpoint::parse("http://127.0.0.1:9000").is_err()); + } + + #[test] + fn parses_durations() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60)); + assert!(parse_duration("1hour").is_err()); + } + + #[test] + fn applies_json_patch() { + let mut object = serde_json::json!({"metadata": {"name": "demo"}}); + let patches = vec![JsonPatch { + op: "replace".to_string(), + path: "/metadata/name".to_string(), + from: String::new(), + value: Some(json_to_proto_value(&serde_json::json!("nvidia-demo")).unwrap()), + }]; + apply_proto_patches(&mut object, &patches).unwrap(); + assert_eq!(object["metadata"]["name"], "nvidia-demo"); + } + + #[test] + fn override_cannot_expand_declared_resources() { + let config = InterceptorConfig { + name: "org".to_string(), + endpoint: "grpc://127.0.0.1:9000".to_string(), + overrides: vec![BindingOverrideConfig { + binding: "b".to_string(), + match_: BindingMatchConfig { + resources: Some(vec!["provider".to_string()]), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }; + let manifest = InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![InterceptorBinding { + id: "b".to_string(), + phases: vec![PHASE_VALIDATE_OBJECT.to_string()], + resources: vec!["sandbox".to_string()], + operations: vec!["create".to_string()], + order: 0, + modifies: false, + default_failure_policy: String::new(), + selector: Some(InterceptorSelector::default()), + }], + }; + let err = plan_bindings(&config, &manifest).unwrap_err(); + assert!(matches!( + err, + InterceptorConfigError::OverrideExpands { + field: "resources", + .. + } + )); + } + + #[test] + fn manifest_rejects_modifying_validation_binding() { + let config = InterceptorConfig { + name: "org".to_string(), + endpoint: "grpc://127.0.0.1:9000".to_string(), + ..Default::default() + }; + let manifest = InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![InterceptorBinding { + id: "b".to_string(), + phases: vec![PHASE_VALIDATE_OBJECT.to_string()], + resources: vec!["sandbox".to_string()], + operations: vec!["create".to_string()], + order: 0, + modifies: true, + default_failure_policy: String::new(), + selector: Some(InterceptorSelector::default()), + }], + }; + let err = validate_manifest(&config, &manifest).unwrap_err(); + assert!(matches!( + err, + InterceptorConfigError::InvalidModifies { .. } + )); + } + + #[test] + fn post_commit_defaults_to_ignore() { + let config = InterceptorConfig { + name: "org".to_string(), + endpoint: "grpc://127.0.0.1:9000".to_string(), + ..Default::default() + }; + let manifest = InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![InterceptorBinding { + id: "audit".to_string(), + phases: vec![PHASE_POST_COMMIT.to_string()], + resources: vec!["sandbox".to_string()], + operations: vec!["create".to_string()], + order: 0, + modifies: false, + default_failure_policy: String::new(), + selector: Some(InterceptorSelector::default()), + }], + }; + let bindings = plan_bindings(&config, &manifest).unwrap(); + assert_eq!(bindings[0].failure_policy, FailurePolicy::Ignore); + } + + #[test] + fn selector_matches_driver_and_labels() { + let config = InterceptorConfig { + name: "org".to_string(), + endpoint: "grpc://127.0.0.1:9000".to_string(), + ..Default::default() + }; + let manifest = InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![InterceptorBinding { + id: "sandbox-policy".to_string(), + phases: vec![PHASE_VALIDATE_OBJECT.to_string()], + resources: vec!["sandbox".to_string()], + operations: vec!["create".to_string()], + order: 0, + modifies: false, + default_failure_policy: String::new(), + selector: Some(InterceptorSelector { + principal_kinds: Vec::new(), + principal_groups: Vec::new(), + labels: BTreeMap::from([("team".to_string(), "platform".to_string())]) + .into_iter() + .collect(), + compute_drivers: vec!["docker".to_string()], + }), + }], + }; + let binding = plan_bindings(&config, &manifest).unwrap().remove(0); + let mut labels = std::collections::HashMap::new(); + labels.insert("team".to_string(), "platform".to_string()); + let input = ReviewInput { + phase: PHASE_VALIDATE_OBJECT.to_string(), + resource: "sandbox".to_string(), + operation: "create".to_string(), + principal: InterceptorPrincipal { + kind: "user".to_string(), + subject: "alice".to_string(), + groups: Vec::new(), + }, + context: InterceptorRequestContext { + request_id: "req-1".to_string(), + gateway_replica_id: "gateway".to_string(), + compute_driver: "docker".to_string(), + dry_run: false, + labels, + }, + object: serde_json::json!({}), + old_object: None, + request: None, + modification_allowed: false, + }; + assert!(binding.matches(&input)); + } +} diff --git a/crates/openshell-prover/Cargo.toml b/crates/openshell-prover/Cargo.toml index ee815f3a3..e273895e1 100644 --- a/crates/openshell-prover/Cargo.toml +++ b/crates/openshell-prover/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true [features] bundled-z3 = ["z3/bundled"] +gh-release-z3 = ["z3/gh-release"] [dependencies] z3 = { workspace = true } diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index cb19cb5f6..c18b2726f 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-interceptors = { path = "../openshell-interceptors" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } @@ -73,6 +74,7 @@ metrics-exporter-prometheus = { workspace = true } # Utilities futures = { workspace = true } bytes = { workspace = true } +base64 = { workspace = true } pin-project-lite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -103,6 +105,7 @@ default = ["telemetry"] ## that contains no telemetry endpoint, HTTP client, or emission code. telemetry = ["openshell-core/telemetry"] bundled-z3 = ["openshell-prover/bundled-z3"] +gh-release-z3 = ["openshell-prover/gh-release-z3"] dev-settings = ["openshell-core/dev-settings"] test-support = [] diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 6d687fb7c..a6b47bb6e 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -430,9 +430,16 @@ impl ComputeRuntime { pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { let driver_sandbox = driver_sandbox_from_public(sandbox, self.driver_kind).map_err(|status| *status)?; + self.validate_driver_sandbox_create(&driver_sandbox).await + } + + pub async fn validate_driver_sandbox_create( + &self, + driver_sandbox: &DriverSandbox, + ) -> Result<(), Status> { self.driver .validate_sandbox_create(Request::new(ValidateSandboxCreateRequest { - sandbox: Some(driver_sandbox), + sandbox: Some(driver_sandbox.clone()), })) .await .map(|_| ()) @@ -1371,7 +1378,8 @@ impl ComputeRuntime { } } -fn driver_sandbox_from_public( +#[allow(clippy::redundant_pub_crate)] +pub(super) fn driver_sandbox_from_public( sandbox: &Sandbox, driver_kind: Option, ) -> Result> { diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 39cf02bba..9ca7c7133 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -26,6 +26,7 @@ use std::path::{Path, PathBuf}; use openshell_core::config::ComputeDriverKind; use openshell_core::{GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig}; +use openshell_interceptors::InterceptorConfig; use serde::{Deserialize, Serialize}; /// Latest schema version this build understands. @@ -150,6 +151,9 @@ pub struct GatewayFileSection { pub mtls_auth: Option, #[serde(default)] pub gateway_jwt: Option, + /// Operation interceptor services configured for the gateway. + #[serde(default)] + pub interceptors: Vec, // ── Disallowed-in-file fields ──────────────────────────────────────── // @@ -360,6 +364,22 @@ supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" client_tls_secret_name = "openshell-sandbox-tls" service_account_name = "openshell-sandbox" +[[openshell.gateway.interceptors]] +name = "org-controls" +endpoint = "unix:///run/openshell/interceptors/org-controls.sock" +order = 100 +timeout = "500ms" +failure_policy = "fail_closed" + +[openshell.gateway.interceptors.config] +sandbox_name_prefix = "nvidia-" + +[[openshell.gateway.interceptors.overrides]] +binding = "driver-config-validation" +failure_policy = "fail_closed" +[openshell.gateway.interceptors.overrides.match] +compute_drivers = ["kubernetes"] + [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" @@ -383,6 +403,9 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ); assert_eq!(gw.grpc_rate_limit_requests, Some(120)); assert_eq!(gw.grpc_rate_limit_window_seconds, Some(60)); + assert_eq!(gw.interceptors.len(), 1); + assert_eq!(gw.interceptors[0].name, "org-controls"); + assert_eq!(gw.interceptors[0].overrides.len(), 1); assert!(gw.tls.is_some()); assert!(gw.oidc.is_some()); assert!(file.openshell.drivers.contains_key("kubernetes")); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 2e2210f44..2e087d9db 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -45,6 +45,7 @@ use openshell_core::{ VERSION, settings::{self, SettingValueKind}, }; +use openshell_interceptors::{PHASE_POST_COMMIT, PHASE_PRE_REQUEST, PHASE_VALIDATE_OBJECT}; use openshell_ocsf::{ ConfigStateChangeBuilder, OCSF_TARGET, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, }; @@ -1438,12 +1439,15 @@ pub(super) async fn handle_update_config( state: &Arc, request: Request, ) -> Result, Status> { + let interceptor_info = crate::interceptors::request_info(&request); let principal = request.extensions().get::().cloned(); let sandbox_caller = matches!(principal, Some(Principal::Sandbox(_))); let update = request.get_ref(); let should_emit_policy_failure = should_emit_config_update_policy_telemetry(sandbox_caller) && (update.policy.is_some() || !update.merge_operations.is_empty()); - let result = handle_update_config_inner(state, request, principal, sandbox_caller).await; + let result = + handle_update_config_inner(state, request, principal, sandbox_caller, interceptor_info) + .await; if result.is_err() && should_emit_policy_failure { emit_sandbox_policy_update_failure(); } @@ -1455,8 +1459,9 @@ async fn handle_update_config_inner( request: Request, principal: Option, sandbox_caller: bool, + interceptor_info: crate::interceptors::InterceptorRequestInfo, ) -> Result, Status> { - let req = request.into_inner(); + let mut req = request.into_inner(); if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( @@ -1468,7 +1473,7 @@ async fn handle_update_config_inner( ) .await?; } - let key = req.setting_key.trim(); + let mut key = req.setting_key.trim().to_string(); let has_policy = req.policy.is_some(); let has_setting = !key.is_empty(); let has_merge_ops = !req.merge_operations.is_empty(); @@ -1488,6 +1493,61 @@ async fn handle_update_config_inner( )); } + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_CONFIG, + update_config_operation(&req), + crate::interceptors::update_config_request_to_json(&req), + None, + None, + HashMap::new(), + ) + .await?; + req = crate::interceptors::update_config_request_from_json(&reviewed.object)?; + if sandbox_caller { + validate_sandbox_caller_update(&req)?; + resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + principal + .as_ref() + .expect("sandbox_caller implies principal"), + &req.name, + ) + .await?; + } + key = req.setting_key.trim().to_string(); + let has_policy = req.policy.is_some(); + let has_setting = !key.is_empty(); + let has_merge_ops = !req.merge_operations.is_empty(); + let mut mutation_count = 0_u8; + mutation_count += u8::from(has_policy); + mutation_count += u8::from(has_setting); + mutation_count += u8::from(has_merge_ops); + if mutation_count > 1 { + return Err(Status::invalid_argument( + "policy, setting_key, and merge_operations are mutually exclusive", + )); + } + if mutation_count == 0 { + return Err(Status::invalid_argument( + "one of policy, setting_key, or merge_operations must be provided", + )); + } + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_CONFIG, + update_config_operation(&req), + crate::interceptors::update_config_request_to_json(&req), + None, + None, + HashMap::new(), + ) + .await?; + if req.global { let _settings_guard = state.settings_mutex.lock().await; @@ -1503,7 +1563,7 @@ async fn handle_update_config_inner( "delete_setting cannot be combined with policy payload", )); } - let mut new_policy = req.policy.ok_or_else(|| { + let mut new_policy = req.policy.clone().ok_or_else(|| { Status::invalid_argument("policy is required for global policy update") })?; openshell_policy::ensure_sandbox_process_identity(&mut new_policy); @@ -1533,12 +1593,18 @@ 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 post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: u32::try_from(current.version).unwrap_or(0), + policy_hash: hash, + settings_revision: global_settings.revision, + deleted: false, + }, + ) + .await; } let next_version = latest.map_or(1, |r| r.version + 1); @@ -1588,12 +1654,18 @@ 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 post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: u32::try_from(next_version).unwrap_or(0), + policy_hash: hash, + settings_revision: global_settings.revision, + deleted: false, + }, + ) + .await; } // Global setting mutation. @@ -1603,12 +1675,12 @@ async fn handle_update_config_inner( )); } if key != POLICY_SETTING_KEY { - validate_registered_setting_key(key)?; + validate_registered_setting_key(&key)?; } let mut global_settings = load_global_settings(state.store.as_ref()).await?; let changed = if req.delete_setting { - let removed = global_settings.settings.remove(key).is_some(); + let removed = global_settings.settings.remove(&key).is_some(); if removed && key == POLICY_SETTING_KEY && let Ok(Some(latest)) = state @@ -1627,8 +1699,8 @@ async fn handle_update_config_inner( .setting_value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; - let stored = proto_setting_to_stored(key, setting)?; - upsert_setting_value(&mut global_settings.settings, key, stored) + let stored = proto_setting_to_stored(&key, setting)?; + upsert_setting_value(&mut global_settings.settings, &key, stored) }; if changed { @@ -1636,12 +1708,18 @@ 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 post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: 0, + policy_hash: String::new(), + settings_revision: global_settings.revision, + deleted: req.delete_setting && changed, + }, + ) + .await; } if req.name.is_empty() { @@ -1669,7 +1747,7 @@ async fn handle_update_config_inner( } let global_settings = load_global_settings(state.store.as_ref()).await?; - let globally_managed = global_settings.settings.contains_key(key); + let globally_managed = global_settings.settings.contains_key(&key); if req.delete_setting { if globally_managed { @@ -1680,7 +1758,7 @@ async fn handle_update_config_inner( let mut sandbox_settings = load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; - let removed = sandbox_settings.settings.remove(key).is_some(); + let removed = sandbox_settings.settings.remove(&key).is_some(); if removed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( @@ -1691,12 +1769,18 @@ 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, - })); + return post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: 0, + policy_hash: String::new(), + settings_revision: sandbox_settings.revision, + deleted: removed, + }, + ) + .await; } if globally_managed { @@ -1709,11 +1793,11 @@ async fn handle_update_config_inner( .setting_value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; - let stored = proto_setting_to_stored(key, setting)?; + let stored = proto_setting_to_stored(&key, setting)?; let mut sandbox_settings = load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; - let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored); + let changed = upsert_setting_value(&mut sandbox_settings.settings, &key, stored); if changed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( @@ -1724,12 +1808,18 @@ 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, - })); + return post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: 0, + policy_hash: String::new(), + settings_revision: sandbox_settings.revision, + deleted: false, + }, + ) + .await; } if has_merge_ops { @@ -1788,17 +1878,24 @@ 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 post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: u32::try_from(version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + }, + ) + .await; } // Sandbox-scoped policy update. let mut new_policy = req .policy + .clone() .ok_or_else(|| Status::invalid_argument("policy is required"))?; let global_settings = load_global_settings(state.store.as_ref()).await?; @@ -1856,12 +1953,18 @@ 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, - })); + return post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: u32::try_from(current.version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + }, + ) + .await; } let next_version = latest.map_or(1, |r| r.version + 1); @@ -1888,12 +1991,57 @@ 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, - })) + post_commit_update_config( + state, + &interceptor_info, + &req, + UpdateConfigResponse { + version: u32::try_from(next_version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + }, + ) + .await +} + +fn update_config_operation(req: &UpdateConfigRequest) -> &'static str { + if !req.merge_operations.is_empty() { + crate::interceptors::OP_MERGE + } else if req.delete_setting { + crate::interceptors::OP_DELETE + } else { + crate::interceptors::OP_UPDATE + } +} + +async fn post_commit_update_config( + state: &Arc, + interceptor_info: &crate::interceptors::InterceptorRequestInfo, + req: &UpdateConfigRequest, + response: UpdateConfigResponse, +) -> Result, Status> { + crate::interceptors::review_json( + state, + interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_CONFIG, + update_config_operation(req), + serde_json::json!({ + "request": crate::interceptors::update_config_request_to_json(req), + "response": { + "version": response.version, + "policy_hash": response.policy_hash, + "settings_revision": response.settings_revision, + "deleted": response.deleted, + }, + }), + None, + None, + HashMap::new(), + ) + .await?; + Ok(Response::new(response)) } // --------------------------------------------------------------------------- diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 641118206..b3ea4459b 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1183,6 +1183,9 @@ use openshell_core::proto::{ ProviderProfileResponse, ProviderResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, StoredProviderProfile, UpdateProviderRequest, }; +use openshell_interceptors::{ + PHASE_MODIFY_OBJECT, PHASE_POST_COMMIT, PHASE_PRE_REQUEST, PHASE_VALIDATE_OBJECT, +}; use openshell_providers::{ CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, default_profiles, get_default_profile, normalize_profile_id, normalize_provider_type, validate_profile_set, @@ -1194,7 +1197,25 @@ pub(super) async fn handle_create_provider( state: &Arc, request: Request, ) -> Result, Status> { - let req = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut req = request.into_inner(); + let original_json = crate::interceptors::create_provider_request_to_json(&req, false); + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_CREATE, + crate::interceptors::create_provider_request_to_json(&req, true), + None, + None, + std::collections::HashMap::new(), + ) + .await?; + crate::interceptors::provider_patch_targets_allowed(&reviewed.applied_patches)?; + let patched = + crate::interceptors::apply_patches_to_original(original_json, &reviewed.applied_patches)?; + req = crate::interceptors::create_provider_request_from_json(&patched)?; let Some(provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1203,6 +1224,51 @@ pub(super) async fn handle_create_provider( ); return Err(Status::invalid_argument("provider is required")); }; + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_MODIFY_OBJECT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_CREATE, + crate::interceptors::provider_to_json(&provider, true), + None, + Some(crate::interceptors::create_provider_request_to_json( + &CreateProviderRequest { + provider: Some(provider.clone()), + }, + true, + )), + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; + crate::interceptors::provider_patch_targets_allowed(&reviewed.applied_patches)?; + let patched = crate::interceptors::apply_patches_to_original( + crate::interceptors::provider_to_json(&provider, false), + &reviewed.applied_patches, + )?; + let provider = crate::interceptors::provider_from_json(&patched)?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_CREATE, + crate::interceptors::provider_to_json(&provider, true), + None, + None, + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; let provider_type = provider.r#type.clone(); let result = create_provider_record(state.store.as_ref(), provider).await; match result { @@ -1212,6 +1278,23 @@ pub(super) async fn handle_create_provider( LifecycleOperation::Create, TelemetryOutcome::Success, ); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_CREATE, + crate::interceptors::provider_to_json(&provider, true), + None, + None, + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; Ok(Response::new(ProviderResponse { provider: Some(provider), })) @@ -1295,7 +1378,33 @@ pub(super) async fn handle_import_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { - let request = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut request = request.into_inner(); + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_PROVIDER_PROFILE, + crate::interceptors::OP_IMPORT, + crate::interceptors::import_provider_profiles_request_to_json(&request), + None, + None, + std::collections::HashMap::new(), + ) + .await?; + request = crate::interceptors::import_provider_profiles_request_from_json(&reviewed.object)?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_PROVIDER_PROFILE, + crate::interceptors::OP_IMPORT, + crate::interceptors::import_provider_profiles_request_to_json(&request), + None, + None, + std::collections::HashMap::new(), + ) + .await?; 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?); @@ -1332,11 +1441,24 @@ pub(super) async fn handle_import_provider_profiles( imported.push(stored.profile.unwrap_or_default()); } - Ok(Response::new(ImportProviderProfilesResponse { + let response = ImportProviderProfilesResponse { diagnostics: Vec::new(), profiles: imported, imported: true, - })) + }; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_PROVIDER_PROFILE, + crate::interceptors::OP_IMPORT, + crate::interceptors::import_provider_profiles_request_to_json(&request), + None, + None, + std::collections::HashMap::new(), + ) + .await?; + Ok(Response::new(response)) } pub(super) async fn handle_lint_provider_profiles( @@ -1360,8 +1482,21 @@ pub(super) async fn handle_delete_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { + let interceptor_info = crate::interceptors::request_info(&request); let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_PROVIDER_PROFILE, + crate::interceptors::OP_DELETE, + serde_json::json!({ "id": id }), + None, + None, + std::collections::HashMap::new(), + ) + .await?; if get_default_profile(&id).is_some() { return Err(Status::failed_precondition( "built-in provider profiles cannot be deleted", @@ -1391,6 +1526,19 @@ pub(super) async fn handle_delete_provider_profile( .await .map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_PROVIDER_PROFILE, + crate::interceptors::OP_DELETE, + serde_json::json!({ "id": id, "deleted": deleted }), + None, + None, + std::collections::HashMap::new(), + ) + .await?; + Ok(Response::new(DeleteProviderProfileResponse { deleted })) } @@ -1721,7 +1869,25 @@ pub(super) async fn handle_update_provider( state: &Arc, request: Request, ) -> Result, Status> { - let req = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut req = request.into_inner(); + let original_json = crate::interceptors::update_provider_request_to_json(&req, false); + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_UPDATE, + crate::interceptors::update_provider_request_to_json(&req, true), + None, + None, + std::collections::HashMap::new(), + ) + .await?; + crate::interceptors::provider_patch_targets_allowed(&reviewed.applied_patches)?; + let patched = + crate::interceptors::apply_patches_to_original(original_json, &reviewed.applied_patches)?; + req = crate::interceptors::update_provider_request_from_json(&patched)?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1730,6 +1896,52 @@ pub(super) async fn handle_update_provider( ); return Err(Status::invalid_argument("provider is required")); }; + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_MODIFY_OBJECT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_UPDATE, + crate::interceptors::provider_to_json(&provider, true), + None, + Some(crate::interceptors::update_provider_request_to_json( + &UpdateProviderRequest { + provider: Some(provider.clone()), + credential_expires_at_ms: req.credential_expires_at_ms.clone(), + }, + true, + )), + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; + crate::interceptors::provider_patch_targets_allowed(&reviewed.applied_patches)?; + let patched = crate::interceptors::apply_patches_to_original( + crate::interceptors::provider_to_json(&provider, false), + &reviewed.applied_patches, + )?; + provider = crate::interceptors::provider_from_json(&patched)?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_UPDATE, + crate::interceptors::provider_to_json(&provider, true), + None, + None, + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; let provider_type = provider.r#type.clone(); provider .credential_expires_at_ms @@ -1742,6 +1954,23 @@ pub(super) async fn handle_update_provider( LifecycleOperation::Update, TelemetryOutcome::Success, ); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_UPDATE, + crate::interceptors::provider_to_json(&provider, true), + None, + None, + provider + .metadata + .as_ref() + .map_or_else(std::collections::HashMap::new, |metadata| { + metadata.labels.clone() + }), + ) + .await?; Ok(Response::new(ProviderResponse { provider: Some(provider), })) @@ -2092,7 +2321,20 @@ pub(super) async fn handle_delete_provider( state: &Arc, request: Request, ) -> Result, Status> { + let interceptor_info = crate::interceptors::request_info(&request); let name = request.into_inner().name; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_DELETE, + serde_json::json!({ "name": name }), + None, + None, + std::collections::HashMap::new(), + ) + .await?; let provider_profile = provider_profile_for_name(state.store.as_ref(), &name).await; let result = delete_provider_record(state.store.as_ref(), &name).await; match result { @@ -2103,6 +2345,18 @@ pub(super) async fn handle_delete_provider( LifecycleOperation::Delete, outcome, ); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_PROVIDER, + crate::interceptors::OP_DELETE, + serde_json::json!({ "name": name, "deleted": deleted }), + None, + None, + std::collections::HashMap::new(), + ) + .await?; Ok(Response::new(DeleteProviderResponse { deleted })) } Err(err) => { diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index e60ce3995..eb1a81cec 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -28,11 +28,16 @@ use openshell_core::telemetry::{ TelemetryOutcome, }; use openshell_core::{ObjectId, ObjectName}; +use openshell_interceptors::{ + PHASE_MODIFY_OBJECT, PHASE_POST_COMMIT, PHASE_PRE_REQUEST, PHASE_VALIDATE_DRIVER, + PHASE_VALIDATE_OBJECT, +}; use prost::Message; +use std::collections::HashMap; use std::net::IpAddr; use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; @@ -118,9 +123,11 @@ async fn handle_create_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { - let request = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut request = request.into_inner(); let spec = request .spec + .clone() .ok_or_else(|| Status::invalid_argument("spec is required"))?; // Validate field sizes before any I/O (fail fast on oversized payloads). @@ -132,6 +139,29 @@ async fn handle_create_sandbox_inner( crate::grpc::validation::validate_label_value(value)?; } + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_CREATE, + crate::interceptors::create_sandbox_request_to_json(&request), + None, + None, + request.labels.clone(), + ) + .await?; + request = crate::interceptors::create_sandbox_request_from_json(&reviewed.object)?; + let spec = request + .spec + .clone() + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + validate_sandbox_spec(&request.name, &spec)?; + for (key, value) in &request.labels { + crate::grpc::validation::validate_label_key(key)?; + crate::grpc::validation::validate_label_value(value)?; + } + // Validate provider names exist (fail fast). for name in &spec.providers { state @@ -179,12 +209,85 @@ async fn handle_create_sandbox_inner( }; sandbox.set_phase(SandboxPhase::Provisioning as i32); - // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) - super::validation::validate_object_metadata(sandbox.metadata.as_ref(), "sandbox")?; + let request_json = crate::interceptors::create_sandbox_request_to_json(&request); + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_MODIFY_OBJECT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_CREATE, + crate::interceptors::sandbox_to_json(&sandbox), + None, + Some(request_json.clone()), + request.labels.clone(), + ) + .await?; + sandbox = crate::interceptors::sandbox_from_json(&reviewed.object)?; + let metadata = sandbox + .metadata + .as_ref() + .ok_or_else(|| Status::invalid_argument("interceptor removed sandbox metadata"))?; + if metadata.id != id { + return Err(Status::invalid_argument( + "interceptor cannot modify sandbox metadata.id", + )); + } + if metadata.created_at_ms != now_ms { + return Err(Status::invalid_argument( + "interceptor cannot modify sandbox metadata.created_at_ms", + )); + } + if metadata.resource_version != 0 { + return Err(Status::invalid_argument( + "interceptor cannot modify sandbox metadata.resource_version", + )); + } + if let Some(spec) = sandbox.spec.as_ref() { + validate_sandbox_spec(sandbox.object_name(), spec)?; + } + let sandbox_labels = metadata.labels.clone(); + + // Interceptors are trusted gateway extensions and may attach metadata + // values that are useful in storage but too long for compute-platform + // labels, such as compact signatures. User-supplied request labels are + // still validated with Kubernetes label-value limits before this point. + super::validation::validate_object_metadata_with_extended_label_values( + sandbox.metadata.as_ref(), + "sandbox", + )?; + + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_CREATE, + crate::interceptors::sandbox_to_json(&sandbox), + None, + Some(request_json.clone()), + sandbox_labels.clone(), + ) + .await?; + + let driver_sandbox = + crate::compute::driver_sandbox_from_public(&sandbox, state.compute.driver_kind()) + .map_err(|status| *status)?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_DRIVER, + crate::interceptors::RESOURCE_DRIVER_SANDBOX, + crate::interceptors::OP_VALIDATE, + crate::interceptors::driver_sandbox_to_json(&driver_sandbox), + None, + Some(request_json), + sandbox_labels, + ) + .await?; state .compute - .validate_sandbox_create(&sandbox) + .validate_driver_sandbox_create(&driver_sandbox) .await .map_err(|status| { warn!(error = %status, "Rejecting sandbox create request"); @@ -213,9 +316,25 @@ async fn handle_create_sandbox_inner( let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_CREATE, + crate::interceptors::sandbox_to_json(&sandbox), + None, + None, + sandbox + .metadata + .as_ref() + .map_or_else(HashMap::new, |metadata| metadata.labels.clone()), + ) + .await?; + info!( - sandbox_id = %id, - sandbox_name = %name, + sandbox_id = %sandbox.object_id(), + sandbox_name = %sandbox.object_name(), "CreateSandbox request completed successfully" ); Ok(Response::new(SandboxResponse { @@ -282,7 +401,8 @@ pub(super) async fn handle_attach_sandbox_provider( state: &Arc, request: Request, ) -> Result, Status> { - let request = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut request = request.into_inner(); if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -297,6 +417,38 @@ pub(super) async fn handle_attach_sandbox_provider( ))); } + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_ATTACH_PROVIDER, + crate::interceptors::attach_provider_request_to_json( + &request.sandbox_name, + &request.provider_name, + request.expected_resource_version, + ), + None, + None, + HashMap::new(), + ) + .await?; + let (sandbox_name, provider_name, expected_resource_version) = + crate::interceptors::attach_provider_request_from_json(&reviewed.object)?; + request.sandbox_name = sandbox_name; + request.provider_name = provider_name; + request.expected_resource_version = expected_resource_version; + if request.provider_name.is_empty() { + return Err(Status::invalid_argument("provider_name is required")); + } + if request.provider_name.len() > super::MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "provider_name exceeds maximum length ({} > {})", + request.provider_name.len(), + super::MAX_NAME_LEN + ))); + } + get_provider_record(state.store.as_ref(), &request.provider_name) .await .map_err(|err| { @@ -350,6 +502,28 @@ pub(super) async fn handle_attach_sandbox_provider( validate_provider_environment_keys_unique(state.store.as_ref(), &candidate_spec.providers) .await?; + let mut candidate_sandbox = sandbox.clone(); + candidate_sandbox.spec = Some(candidate_spec); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_ATTACH_PROVIDER, + crate::interceptors::sandbox_to_json(&candidate_sandbox), + Some(crate::interceptors::sandbox_to_json(&sandbox)), + Some(crate::interceptors::attach_provider_request_to_json( + &request.sandbox_name, + &request.provider_name, + request.expected_resource_version, + )), + sandbox + .metadata + .as_ref() + .map_or_else(HashMap::new, |metadata| metadata.labels.clone()), + ) + .await?; + let provider_name = request.provider_name.clone(); let attached = Arc::new(AtomicBool::new(false)); let attached_clone = attached.clone(); @@ -386,6 +560,22 @@ pub(super) async fn handle_attach_sandbox_provider( "AttachSandboxProvider request completed successfully" ); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_ATTACH_PROVIDER, + crate::interceptors::sandbox_to_json(&sandbox), + None, + None, + sandbox + .metadata + .as_ref() + .map_or_else(HashMap::new, |metadata| metadata.labels.clone()), + ) + .await?; + Ok(Response::new(AttachSandboxProviderResponse { sandbox: Some(sandbox), attached, @@ -396,7 +586,8 @@ pub(super) async fn handle_detach_sandbox_provider( state: &Arc, request: Request, ) -> Result, Status> { - let request = request.into_inner(); + let interceptor_info = crate::interceptors::request_info(&request); + let mut request = request.into_inner(); if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -410,6 +601,30 @@ pub(super) async fn handle_detach_sandbox_provider( ))); } + let reviewed = crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_PRE_REQUEST, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_DETACH_PROVIDER, + crate::interceptors::detach_provider_request_to_json(&request), + None, + None, + HashMap::new(), + ) + .await?; + request = crate::interceptors::detach_provider_request_from_json(&reviewed.object)?; + if request.provider_name.is_empty() { + return Err(Status::invalid_argument("provider_name is required")); + } + if request.provider_name.len() > super::MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "provider_name exceeds maximum length ({} > {})", + request.provider_name.len(), + super::MAX_NAME_LEN + ))); + } + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; let sandbox_id = sandbox @@ -425,6 +640,29 @@ pub(super) async fn handle_detach_sandbox_provider( .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + let mut candidate_sandbox = sandbox.clone(); + if let Some(ref mut spec) = candidate_sandbox.spec { + spec.providers.retain(|name| name != &request.provider_name); + dedupe_provider_names(&mut spec.providers); + } + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_VALIDATE_OBJECT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_DETACH_PROVIDER, + crate::interceptors::sandbox_to_json(&candidate_sandbox), + Some(crate::interceptors::sandbox_to_json(&sandbox)), + Some(crate::interceptors::detach_provider_request_to_json( + &request, + )), + sandbox + .metadata + .as_ref() + .map_or_else(HashMap::new, |metadata| metadata.labels.clone()), + ) + .await?; + let provider_name = request.provider_name.clone(); let detached = Arc::new(AtomicBool::new(false)); let detached_clone = detached.clone(); @@ -461,6 +699,22 @@ pub(super) async fn handle_detach_sandbox_provider( "DetachSandboxProvider request completed successfully" ); + crate::interceptors::review_json( + state, + &interceptor_info, + PHASE_POST_COMMIT, + crate::interceptors::RESOURCE_SANDBOX, + crate::interceptors::OP_DETACH_PROVIDER, + crate::interceptors::sandbox_to_json(&sandbox), + None, + None, + sandbox + .metadata + .as_ref() + .map_or_else(HashMap::new, |metadata| metadata.labels.clone()), + ) + .await?; + Ok(Response::new(DetachSandboxProviderResponse { sandbox: Some(sandbox), detached, @@ -1040,8 +1294,8 @@ async fn validate_ssh_forward_token( } fn acquire_ssh_connection_slots( - token_counts: &std::sync::Mutex>, - sandbox_counts: &std::sync::Mutex>, + token_counts: &Mutex>, + sandbox_counts: &Mutex>, token: &str, sandbox_id: &str, ) -> Result<(), Status> { @@ -1074,10 +1328,7 @@ fn acquire_ssh_connection_slots( Ok(()) } -fn decrement_ssh_connection_count( - counts: &std::sync::Mutex>, - key: &str, -) { +fn decrement_ssh_connection_count(counts: &Mutex>, key: &str) { let mut counts = counts.lock().unwrap(); if let Some(count) = counts.get_mut(key) { *count = count.saturating_sub(1); @@ -1350,7 +1601,7 @@ pub(super) async fn handle_create_ssh_session( id: token.clone(), name: generate_name(), created_at_ms: now_ms, - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, }), sandbox_id: req.sandbox_id.clone(), diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 03a69d6e9..20c6f9a9e 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -573,6 +573,11 @@ pub(super) fn validate_label_selector(selector: &str) -> Result<(), Status> { // Object metadata validation // --------------------------------------------------------------------------- +enum LabelValueMode { + Kubernetes, + Extended, +} + /// Validate that object metadata is present and contains required fields. /// /// This ensures that all resources have valid metadata with non-empty ID and name, @@ -583,6 +588,21 @@ pub(super) fn validate_label_selector(selector: &str) -> Result<(), Status> { pub(super) fn validate_object_metadata( metadata: Option<&openshell_core::proto::datamodel::v1::ObjectMeta>, resource_type: &str, +) -> Result<(), Status> { + validate_object_metadata_with_label_values(metadata, resource_type, LabelValueMode::Kubernetes) +} + +pub(super) fn validate_object_metadata_with_extended_label_values( + metadata: Option<&openshell_core::proto::datamodel::v1::ObjectMeta>, + resource_type: &str, +) -> Result<(), Status> { + validate_object_metadata_with_label_values(metadata, resource_type, LabelValueMode::Extended) +} + +fn validate_object_metadata_with_label_values( + metadata: Option<&openshell_core::proto::datamodel::v1::ObjectMeta>, + resource_type: &str, + label_value_mode: LabelValueMode, ) -> Result<(), Status> { let metadata = metadata .ok_or_else(|| Status::invalid_argument(format!("{resource_type} metadata is required")))?; @@ -602,7 +622,46 @@ pub(super) fn validate_object_metadata( // Validate all labels in metadata for (key, value) in &metadata.labels { validate_label_key(key)?; - validate_label_value(value)?; + match label_value_mode { + LabelValueMode::Kubernetes => validate_label_value(value)?, + LabelValueMode::Extended => validate_extended_label_value(value)?, + } + } + + Ok(()) +} + +fn validate_extended_label_value(value: &str) -> Result<(), Status> { + if value.is_empty() { + return Ok(()); + } + + if value.len() > 4096 { + return Err(Status::invalid_argument(format!( + "label value exceeds 4096 characters: '{value}'" + ))); + } + + if !value + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err(Status::invalid_argument(format!( + "label value contains invalid characters (must be alphanumeric, '-', '_', or '.'): '{value}'" + ))); + } + + let first = value.chars().next().unwrap(); + let last = value.chars().last().unwrap(); + if !first.is_alphanumeric() { + return Err(Status::invalid_argument(format!( + "label value must start with alphanumeric character: '{value}'" + ))); + } + if !last.is_alphanumeric() { + return Err(Status::invalid_argument(format!( + "label value must end with alphanumeric character: '{value}'" + ))); } Ok(()) @@ -1393,6 +1452,19 @@ mod tests { assert!(err.message().contains("invalid characters")); } + #[test] + fn validate_extended_label_value_accepts_jwt_shaped_values() { + let jwt = format!("{}.{}.{}", "e".repeat(80), "p".repeat(220), "s".repeat(43)); + assert!(validate_extended_label_value(&jwt).is_ok()); + } + + #[test] + fn validate_extended_label_value_rejects_invalid_characters() { + let err = validate_extended_label_value("header.payload.signature/extra").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("invalid characters")); + } + // ---- Label selector validation ---- #[test] diff --git a/crates/openshell-server/src/interceptors.rs b/crates/openshell-server/src/interceptors.rs new file mode 100644 index 000000000..8a4a85875 --- /dev/null +++ b/crates/openshell-server/src/interceptors.rs @@ -0,0 +1,1634 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Server-side gateway interceptor integration helpers. + +#![allow( + clippy::result_large_err, + clippy::redundant_pub_crate, + clippy::too_many_arguments, + clippy::unnecessary_wraps +)] + +use std::collections::HashMap; + +use base64::Engine as _; +use openshell_core::proto::compute::v1::{ + DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, + DriverSandboxTemplate, +}; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::interceptor::v1::InterceptorRequestContext as ProtoInterceptorRequestContext; +use openshell_core::proto::setting_value; +use openshell_core::proto::{ + AddAllowRules, AddDenyRules, AddNetworkRule, CreateProviderRequest, CreateSandboxRequest, + DetachSandboxProviderRequest, FilesystemPolicy, GraphqlOperation, + ImportProviderProfilesRequest, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyMergeOperation, ProcessPolicy, + Provider, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, + ProviderProfileDiscovery, ProviderProfileImportItem, RemoveNetworkBinary, + RemoveNetworkEndpoint, RemoveNetworkRule, Sandbox, SandboxCondition, SandboxPolicy, + SandboxSpec, SandboxStatus, SandboxTemplate, SettingValue, UpdateConfigRequest, + UpdateProviderRequest, policy_merge_operation, +}; +use openshell_interceptors::{ + PHASE_MODIFY_OBJECT, PHASE_PRE_REQUEST, ReviewError, ReviewInput, ReviewOutcome, + apply_proto_patches, +}; +use openshell_interceptors::{json_to_struct, struct_to_json}; +use openshell_ocsf::{ + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, FindingInfo, OCSF_TARGET, + OcsfEvent, RiskLevelId, SandboxContext, SeverityId, +}; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use tonic::{Request, Status}; +use tracing::info; + +use crate::ServerState; +use crate::auth::principal::Principal; + +pub(crate) const RESOURCE_SANDBOX: &str = "sandbox"; +pub(crate) const RESOURCE_PROVIDER: &str = "provider"; +pub(crate) const RESOURCE_PROVIDER_PROFILE: &str = "provider_profile"; +pub(crate) const RESOURCE_CONFIG: &str = "config"; +pub(crate) const RESOURCE_DRIVER_SANDBOX: &str = "driver_sandbox"; + +pub(crate) const OP_CREATE: &str = "create"; +pub(crate) const OP_UPDATE: &str = "update"; +pub(crate) const OP_DELETE: &str = "delete"; +pub(crate) const OP_IMPORT: &str = "import"; +pub(crate) const OP_ATTACH_PROVIDER: &str = "attach_provider"; +pub(crate) const OP_DETACH_PROVIDER: &str = "detach_provider"; +pub(crate) const OP_MERGE: &str = "merge"; +pub(crate) const OP_VALIDATE: &str = "validate"; + +#[derive(Debug, Clone)] +pub(crate) struct InterceptorRequestInfo { + principal: openshell_core::proto::interceptor::v1::InterceptorPrincipal, + request_id: String, +} + +pub(crate) fn request_info(request: &Request) -> InterceptorRequestInfo { + InterceptorRequestInfo { + principal: interceptor_principal(request.extensions().get::()), + request_id: request + .metadata() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .map_or_else(|| uuid::Uuid::new_v4().to_string(), ToString::to_string), + } +} + +pub(crate) async fn review_json( + state: &ServerState, + info: &InterceptorRequestInfo, + phase: &str, + resource: &str, + operation: &str, + object: JsonValue, + old_object: Option, + request: Option, + labels: HashMap, +) -> Result { + if state.interceptors.is_empty() { + return Ok(ReviewOutcome { + object, + applied_patches: Vec::new(), + warnings: Vec::new(), + audit_annotations: std::collections::BTreeMap::new(), + }); + } + let input = ReviewInput { + phase: phase.to_string(), + resource: resource.to_string(), + operation: operation.to_string(), + principal: info.principal.clone(), + context: ProtoInterceptorRequestContext { + request_id: info.request_id.clone(), + gateway_replica_id: "openshell-gateway".to_string(), + compute_driver: state + .compute + .driver_kind() + .map_or_else(String::new, |driver| driver.as_str().to_string()), + dry_run: false, + labels, + }, + object, + old_object, + request, + modification_allowed: matches!(phase, PHASE_PRE_REQUEST | PHASE_MODIFY_OBJECT), + }; + state + .interceptors + .review(input) + .await + .map_err(|error| review_error_to_status(error, phase, resource, operation)) +} + +fn review_error_to_status( + error: ReviewError, + phase: &str, + resource: &str, + operation: &str, +) -> Status { + match &error { + ReviewError::Denied { + interceptor, + binding, + phase, + resource, + operation, + reason, + .. + } => { + emit_interceptor_denial(interceptor, binding, phase, resource, operation, reason); + } + ReviewError::Failed(status) => { + emit_interceptor_failure(phase, resource, operation, status); + } + } + error.into_status() +} + +fn emit_interceptor_denial( + interceptor: &str, + binding: &str, + phase: &str, + resource: &str, + operation: &str, + reason: &str, +) { + let ctx = gateway_ocsf_ctx(); + let event = DetectionFindingBuilder::new(&ctx) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .risk_level(RiskLevelId::High) + .finding_info( + FindingInfo::new("gateway_interceptor_denial", "Gateway interceptor denial") + .with_desc(reason), + ) + .evidence_pairs(&[ + ("interceptor", interceptor), + ("binding", binding), + ("phase", phase), + ("resource", resource), + ("operation", operation), + ]) + .message(format!( + "Gateway interceptor denied {resource}.{operation} during {phase}: {reason}" + )) + .build(); + emit_gateway_ocsf_event(event); +} + +fn emit_interceptor_failure(phase: &str, resource: &str, operation: &str, status: &Status) { + let ctx = gateway_ocsf_ctx(); + let code = status.code().to_string(); + let event = DetectionFindingBuilder::new(&ctx) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .risk_level(RiskLevelId::High) + .finding_info( + FindingInfo::new("gateway_interceptor_failure", "Gateway interceptor failure") + .with_desc(status.message()), + ) + .evidence_pairs(&[ + ("phase", phase), + ("resource", resource), + ("operation", operation), + ("code", code.as_str()), + ]) + .message(format!( + "Gateway interceptor failed {resource}.{operation} during {phase}: {}", + status.message() + )) + .build(); + emit_gateway_ocsf_event(event); +} + +fn emit_gateway_ocsf_event(event: OcsfEvent) { + let message = event.format_shorthand(); + info!(target: OCSF_TARGET, sandbox_id = "", message = %message); +} + +fn gateway_ocsf_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: String::new(), + sandbox_name: String::new(), + container_image: "openshell/gateway".to_string(), + hostname: "openshell-gateway".to_string(), + product_version: openshell_core::VERSION.to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 0, + } +} + +fn interceptor_principal( + principal: Option<&Principal>, +) -> openshell_core::proto::interceptor::v1::InterceptorPrincipal { + match principal { + Some(Principal::User(user)) => { + openshell_core::proto::interceptor::v1::InterceptorPrincipal { + kind: "user".to_string(), + subject: user.identity.subject.clone(), + groups: user.identity.roles.clone(), + } + } + Some(Principal::Sandbox(sandbox)) => { + openshell_core::proto::interceptor::v1::InterceptorPrincipal { + kind: "sandbox".to_string(), + subject: sandbox.sandbox_id.clone(), + groups: Vec::new(), + } + } + Some(Principal::Anonymous) | None => { + openshell_core::proto::interceptor::v1::InterceptorPrincipal { + kind: "anonymous".to_string(), + subject: String::new(), + groups: Vec::new(), + } + } + } +} + +pub(crate) fn provider_patch_targets_allowed( + patches: &[openshell_core::proto::interceptor::v1::JsonPatch], +) -> Result<(), Status> { + for patch in patches { + if patch.path == "/credentials" || patch.path.starts_with("/credentials/") { + return Err(Status::permission_denied( + "interceptor patches cannot modify provider credential values", + )); + } + if patch.from == "/credentials" || patch.from.starts_with("/credentials/") { + return Err(Status::permission_denied( + "interceptor patches cannot read provider credential values", + )); + } + } + Ok(()) +} + +pub(crate) fn apply_patches_to_original( + mut object: JsonValue, + patches: &[openshell_core::proto::interceptor::v1::JsonPatch], +) -> Result { + apply_proto_patches(&mut object, patches).map_err(|err| { + Status::invalid_argument(format!("apply interceptor patches failed: {err}")) + })?; + Ok(object) +} + +pub(crate) fn create_sandbox_request_to_json(request: &CreateSandboxRequest) -> JsonValue { + json!({ + "spec": request.spec.as_ref().map(sandbox_spec_to_json), + "name": request.name, + "labels": request.labels, + }) +} + +pub(crate) fn create_sandbox_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "CreateSandboxRequest")?; + Ok(CreateSandboxRequest { + spec: optional_object_field(object, "spec")? + .map(sandbox_spec_from_json) + .transpose()?, + name: string_field(object, "name")?, + labels: string_map_field(object, "labels")?, + }) +} + +pub(crate) fn attach_provider_request_to_json( + sandbox_name: &str, + provider_name: &str, + expected_resource_version: u64, +) -> JsonValue { + json!({ + "sandbox_name": sandbox_name, + "provider_name": provider_name, + "expected_resource_version": expected_resource_version, + }) +} + +pub(crate) fn attach_provider_request_from_json( + value: &JsonValue, +) -> Result<(String, String, u64), Status> { + let object = expect_object(value, "AttachSandboxProviderRequest")?; + Ok(( + string_field(object, "sandbox_name")?, + string_field(object, "provider_name")?, + u64_field(object, "expected_resource_version")?, + )) +} + +pub(crate) fn detach_provider_request_to_json(request: &DetachSandboxProviderRequest) -> JsonValue { + json!({ + "sandbox_name": request.sandbox_name, + "provider_name": request.provider_name, + "expected_resource_version": request.expected_resource_version, + }) +} + +pub(crate) fn detach_provider_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "DetachSandboxProviderRequest")?; + Ok(DetachSandboxProviderRequest { + sandbox_name: string_field(object, "sandbox_name")?, + provider_name: string_field(object, "provider_name")?, + expected_resource_version: u64_field(object, "expected_resource_version")?, + }) +} + +pub(crate) fn sandbox_to_json(sandbox: &Sandbox) -> JsonValue { + json!({ + "metadata": sandbox.metadata.as_ref().map(object_meta_to_json), + "spec": sandbox.spec.as_ref().map(sandbox_spec_to_json), + "status": sandbox.status.as_ref().map(sandbox_status_to_json), + }) +} + +pub(crate) fn sandbox_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "Sandbox")?; + Ok(Sandbox { + metadata: optional_object_field(object, "metadata")? + .map(object_meta_from_json) + .transpose()?, + spec: optional_object_field(object, "spec")? + .map(sandbox_spec_from_json) + .transpose()?, + status: optional_object_field(object, "status")? + .map(sandbox_status_from_json) + .transpose()?, + }) +} + +pub(crate) fn driver_sandbox_to_json(sandbox: &DriverSandbox) -> JsonValue { + json!({ + "id": sandbox.id, + "name": sandbox.name, + "namespace": sandbox.namespace, + "spec": sandbox.spec.as_ref().map(driver_sandbox_spec_to_json), + "status": sandbox.status.as_ref().map(driver_sandbox_status_to_json), + }) +} + +pub(crate) fn create_provider_request_to_json( + request: &CreateProviderRequest, + redact_credentials: bool, +) -> JsonValue { + json!({ + "provider": request.provider.as_ref().map(|provider| provider_to_json(provider, redact_credentials)), + }) +} + +pub(crate) fn create_provider_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "CreateProviderRequest")?; + Ok(CreateProviderRequest { + provider: optional_object_field(object, "provider")? + .map(provider_from_json) + .transpose()?, + }) +} + +pub(crate) fn update_provider_request_to_json( + request: &UpdateProviderRequest, + redact_credentials: bool, +) -> JsonValue { + json!({ + "provider": request.provider.as_ref().map(|provider| provider_to_json(provider, redact_credentials)), + "credential_expires_at_ms": request.credential_expires_at_ms, + }) +} + +pub(crate) fn update_provider_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "UpdateProviderRequest")?; + Ok(UpdateProviderRequest { + provider: optional_object_field(object, "provider")? + .map(provider_from_json) + .transpose()?, + credential_expires_at_ms: i64_map_field(object, "credential_expires_at_ms")?, + }) +} + +pub(crate) fn provider_to_json(provider: &Provider, redact_credentials: bool) -> JsonValue { + let credentials = if redact_credentials { + provider + .credentials + .keys() + .map(|key| (key.clone(), JsonValue::String("REDACTED".to_string()))) + .collect::>() + } else { + provider + .credentials + .iter() + .map(|(key, value)| (key.clone(), JsonValue::String(value.clone()))) + .collect::>() + }; + json!({ + "metadata": provider.metadata.as_ref().map(object_meta_to_json), + "type": provider.r#type, + "credentials": credentials, + "config": provider.config, + "credential_expires_at_ms": provider.credential_expires_at_ms, + }) +} + +pub(crate) fn provider_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "Provider")?; + Ok(Provider { + metadata: optional_object_field(object, "metadata")? + .map(object_meta_from_json) + .transpose()?, + r#type: string_field(object, "type")?, + credentials: string_map_field(object, "credentials")?, + config: string_map_field(object, "config")?, + credential_expires_at_ms: i64_map_field(object, "credential_expires_at_ms")?, + }) +} + +pub(crate) fn import_provider_profiles_request_to_json( + request: &ImportProviderProfilesRequest, +) -> JsonValue { + json!({ + "profiles": request.profiles.iter().map(provider_profile_import_item_to_json).collect::>(), + }) +} + +pub(crate) fn import_provider_profiles_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ImportProviderProfilesRequest")?; + Ok(ImportProviderProfilesRequest { + profiles: array_field(object, "profiles")? + .iter() + .map(provider_profile_import_item_from_json) + .collect::>()?, + }) +} + +pub(crate) fn update_config_request_to_json(request: &UpdateConfigRequest) -> JsonValue { + json!({ + "name": request.name, + "policy": request.policy.as_ref().map(sandbox_policy_to_json), + "setting_key": request.setting_key, + "setting_value": request.setting_value.as_ref().map(setting_value_to_json), + "delete_setting": request.delete_setting, + "global": request.global, + "merge_operations": request.merge_operations.iter().map(policy_merge_operation_to_json).collect::>(), + "expected_resource_version": request.expected_resource_version, + }) +} + +pub(crate) fn update_config_request_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "UpdateConfigRequest")?; + Ok(UpdateConfigRequest { + name: string_field(object, "name")?, + policy: optional_object_field(object, "policy")? + .map(sandbox_policy_from_json) + .transpose()?, + setting_key: string_field(object, "setting_key")?, + setting_value: optional_object_field(object, "setting_value")? + .map(setting_value_from_json) + .transpose()?, + delete_setting: bool_field(object, "delete_setting")?, + global: bool_field(object, "global")?, + merge_operations: array_field(object, "merge_operations")? + .iter() + .map(policy_merge_operation_from_json) + .collect::>()?, + expected_resource_version: u64_field(object, "expected_resource_version")?, + }) +} + +fn object_meta_to_json(metadata: &ObjectMeta) -> JsonValue { + json!({ + "id": metadata.id, + "name": metadata.name, + "created_at_ms": metadata.created_at_ms, + "labels": metadata.labels, + "resource_version": metadata.resource_version, + }) +} + +fn object_meta_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "ObjectMeta")?; + Ok(ObjectMeta { + id: string_field(object, "id")?, + name: string_field(object, "name")?, + created_at_ms: i64_field(object, "created_at_ms")?, + labels: string_map_field(object, "labels")?, + resource_version: u64_field(object, "resource_version")?, + }) +} + +fn sandbox_spec_to_json(spec: &SandboxSpec) -> JsonValue { + json!({ + "log_level": spec.log_level, + "environment": spec.environment, + "template": spec.template.as_ref().map(sandbox_template_to_json), + "policy": spec.policy.as_ref().map(sandbox_policy_to_json), + "providers": spec.providers, + "gpu": spec.gpu, + }) +} + +fn sandbox_spec_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SandboxSpec")?; + Ok(SandboxSpec { + log_level: string_field(object, "log_level")?, + environment: string_map_field(object, "environment")?, + template: optional_object_field(object, "template")? + .map(sandbox_template_from_json) + .transpose()?, + policy: optional_object_field(object, "policy")? + .map(sandbox_policy_from_json) + .transpose()?, + providers: string_array_field(object, "providers")?, + gpu: bool_field(object, "gpu")?, + }) +} + +fn sandbox_template_to_json(template: &SandboxTemplate) -> JsonValue { + json!({ + "image": template.image, + "runtime_class_name": template.runtime_class_name, + "agent_socket": template.agent_socket, + "labels": template.labels, + "annotations": template.annotations, + "environment": template.environment, + "resources": template.resources.as_ref().map(struct_to_json), + "volume_claim_templates": template.volume_claim_templates.as_ref().map(struct_to_json), + "user_namespaces": template.user_namespaces, + "driver_config": template.driver_config.as_ref().map(struct_to_json), + }) +} + +fn sandbox_template_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SandboxTemplate")?; + Ok(SandboxTemplate { + image: string_field(object, "image")?, + runtime_class_name: string_field(object, "runtime_class_name")?, + agent_socket: string_field(object, "agent_socket")?, + labels: string_map_field(object, "labels")?, + annotations: string_map_field(object, "annotations")?, + environment: string_map_field(object, "environment")?, + resources: optional_json_field(object, "resources") + .map(json_to_struct_status) + .transpose()?, + volume_claim_templates: optional_json_field(object, "volume_claim_templates") + .map(json_to_struct_status) + .transpose()?, + user_namespaces: optional_bool_field(object, "user_namespaces")?, + driver_config: optional_json_field(object, "driver_config") + .map(json_to_struct_status) + .transpose()?, + }) +} + +fn sandbox_status_to_json(status: &SandboxStatus) -> JsonValue { + json!({ + "sandbox_name": status.sandbox_name, + "agent_pod": status.agent_pod, + "agent_fd": status.agent_fd, + "sandbox_fd": status.sandbox_fd, + "conditions": status.conditions.iter().map(sandbox_condition_to_json).collect::>(), + "phase": status.phase, + "current_policy_version": status.current_policy_version, + }) +} + +fn sandbox_status_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SandboxStatus")?; + Ok(SandboxStatus { + sandbox_name: string_field(object, "sandbox_name")?, + agent_pod: string_field(object, "agent_pod")?, + agent_fd: string_field(object, "agent_fd")?, + sandbox_fd: string_field(object, "sandbox_fd")?, + conditions: array_field(object, "conditions")? + .iter() + .map(sandbox_condition_from_json) + .collect::>()?, + phase: i32_field(object, "phase")?, + current_policy_version: u32_field(object, "current_policy_version")?, + }) +} + +fn sandbox_condition_to_json(condition: &SandboxCondition) -> JsonValue { + json!({ + "type": condition.r#type, + "status": condition.status, + "reason": condition.reason, + "message": condition.message, + "last_transition_time": condition.last_transition_time, + }) +} + +fn sandbox_condition_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SandboxCondition")?; + Ok(SandboxCondition { + r#type: string_field(object, "type")?, + status: string_field(object, "status")?, + reason: string_field(object, "reason")?, + message: string_field(object, "message")?, + last_transition_time: string_field(object, "last_transition_time")?, + }) +} + +fn sandbox_policy_to_json(policy: &SandboxPolicy) -> JsonValue { + json!({ + "version": policy.version, + "filesystem": policy.filesystem.as_ref().map(filesystem_policy_to_json), + "landlock": policy.landlock.as_ref().map(landlock_policy_to_json), + "process": policy.process.as_ref().map(process_policy_to_json), + "network_policies": policy.network_policies.iter().map(|(key, value)| (key.clone(), network_policy_rule_to_json(value))).collect::>(), + }) +} + +fn sandbox_policy_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SandboxPolicy")?; + Ok(SandboxPolicy { + version: u32_field(object, "version")?, + filesystem: optional_object_field(object, "filesystem")? + .map(filesystem_policy_from_json) + .transpose()?, + landlock: optional_object_field(object, "landlock")? + .map(landlock_policy_from_json) + .transpose()?, + process: optional_object_field(object, "process")? + .map(process_policy_from_json) + .transpose()?, + network_policies: object + .get("network_policies") + .and_then(JsonValue::as_object) + .map_or_else( + || Ok(HashMap::new()), + |policies| { + policies + .iter() + .map(|(key, value)| { + Ok((key.clone(), network_policy_rule_from_json(value)?)) + }) + .collect::, Status>>() + }, + )?, + }) +} + +fn filesystem_policy_to_json(policy: &FilesystemPolicy) -> JsonValue { + json!({ + "include_workdir": policy.include_workdir, + "read_only": policy.read_only, + "read_write": policy.read_write, + }) +} + +fn filesystem_policy_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "FilesystemPolicy")?; + Ok(FilesystemPolicy { + include_workdir: bool_field(object, "include_workdir")?, + read_only: string_array_field(object, "read_only")?, + read_write: string_array_field(object, "read_write")?, + }) +} + +fn landlock_policy_to_json(policy: &LandlockPolicy) -> JsonValue { + json!({ "compatibility": policy.compatibility }) +} + +fn landlock_policy_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "LandlockPolicy")?; + Ok(LandlockPolicy { + compatibility: string_field(object, "compatibility")?, + }) +} + +fn process_policy_to_json(policy: &ProcessPolicy) -> JsonValue { + json!({ + "run_as_user": policy.run_as_user, + "run_as_group": policy.run_as_group, + }) +} + +fn process_policy_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "ProcessPolicy")?; + Ok(ProcessPolicy { + run_as_user: string_field(object, "run_as_user")?, + run_as_group: string_field(object, "run_as_group")?, + }) +} + +fn network_policy_rule_to_json(rule: &NetworkPolicyRule) -> JsonValue { + json!({ + "name": rule.name, + "endpoints": rule.endpoints.iter().map(network_endpoint_to_json).collect::>(), + "binaries": rule.binaries.iter().map(network_binary_to_json).collect::>(), + }) +} + +fn network_policy_rule_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "NetworkPolicyRule")?; + Ok(NetworkPolicyRule { + name: string_field(object, "name")?, + endpoints: array_field(object, "endpoints")? + .iter() + .map(network_endpoint_from_json) + .collect::>()?, + binaries: array_field(object, "binaries")? + .iter() + .map(network_binary_from_json) + .collect::>()?, + }) +} + +fn network_endpoint_to_json(endpoint: &NetworkEndpoint) -> JsonValue { + json!({ + "host": endpoint.host, + "port": endpoint.port, + "protocol": endpoint.protocol, + "tls": endpoint.tls, + "enforcement": endpoint.enforcement, + "access": endpoint.access, + "rules": endpoint.rules.iter().map(l7_rule_to_json).collect::>(), + "allowed_ips": endpoint.allowed_ips, + "ports": endpoint.ports, + "deny_rules": endpoint.deny_rules.iter().map(l7_deny_rule_to_json).collect::>(), + "allow_encoded_slash": endpoint.allow_encoded_slash, + "persisted_queries": endpoint.persisted_queries, + "graphql_persisted_queries": endpoint.graphql_persisted_queries.iter().map(|(key, value)| (key.clone(), graphql_operation_to_json(value))).collect::>(), + "graphql_max_body_bytes": endpoint.graphql_max_body_bytes, + "path": endpoint.path, + "websocket_credential_rewrite": endpoint.websocket_credential_rewrite, + "request_body_credential_rewrite": endpoint.request_body_credential_rewrite, + "advisor_proposed": endpoint.advisor_proposed, + }) +} + +fn network_endpoint_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "NetworkEndpoint")?; + Ok(NetworkEndpoint { + host: string_field(object, "host")?, + port: u32_field(object, "port")?, + protocol: string_field(object, "protocol")?, + tls: string_field(object, "tls")?, + enforcement: string_field(object, "enforcement")?, + access: string_field(object, "access")?, + rules: array_field(object, "rules")? + .iter() + .map(l7_rule_from_json) + .collect::>()?, + allowed_ips: string_array_field(object, "allowed_ips")?, + ports: u32_array_field(object, "ports")?, + deny_rules: array_field(object, "deny_rules")? + .iter() + .map(l7_deny_rule_from_json) + .collect::>()?, + allow_encoded_slash: bool_field(object, "allow_encoded_slash")?, + persisted_queries: string_field(object, "persisted_queries")?, + graphql_persisted_queries: object + .get("graphql_persisted_queries") + .and_then(JsonValue::as_object) + .map_or_else( + || Ok(HashMap::new()), + |queries| { + queries + .iter() + .map(|(key, value)| Ok((key.clone(), graphql_operation_from_json(value)?))) + .collect::, Status>>() + }, + )?, + graphql_max_body_bytes: u32_field(object, "graphql_max_body_bytes")?, + path: string_field(object, "path")?, + websocket_credential_rewrite: bool_field(object, "websocket_credential_rewrite")?, + request_body_credential_rewrite: bool_field(object, "request_body_credential_rewrite")?, + advisor_proposed: bool_field(object, "advisor_proposed")?, + }) +} + +#[allow(deprecated)] +fn network_binary_to_json(binary: &NetworkBinary) -> JsonValue { + json!({ "path": binary.path, "harness": binary.harness }) +} + +#[allow(deprecated)] +fn network_binary_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "NetworkBinary")?; + Ok(NetworkBinary { + path: string_field(object, "path")?, + harness: bool_field(object, "harness")?, + }) +} + +fn l7_rule_to_json(rule: &L7Rule) -> JsonValue { + json!({ "allow": rule.allow.as_ref().map(l7_allow_to_json) }) +} + +fn l7_rule_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "L7Rule")?; + Ok(L7Rule { + allow: optional_object_field(object, "allow")? + .map(l7_allow_from_json) + .transpose()?, + }) +} + +fn l7_allow_to_json(allow: &L7Allow) -> JsonValue { + json!({ + "method": allow.method, + "path": allow.path, + "command": allow.command, + "query": allow.query.iter().map(|(key, value)| (key.clone(), l7_query_matcher_to_json(value))).collect::>(), + "operation_type": allow.operation_type, + "operation_name": allow.operation_name, + "fields": allow.fields, + }) +} + +fn l7_allow_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "L7Allow")?; + Ok(L7Allow { + method: string_field(object, "method")?, + path: string_field(object, "path")?, + command: string_field(object, "command")?, + query: query_matcher_map_field(object, "query")?, + operation_type: string_field(object, "operation_type")?, + operation_name: string_field(object, "operation_name")?, + fields: string_array_field(object, "fields")?, + }) +} + +fn l7_deny_rule_to_json(rule: &L7DenyRule) -> JsonValue { + json!({ + "method": rule.method, + "path": rule.path, + "command": rule.command, + "query": rule.query.iter().map(|(key, value)| (key.clone(), l7_query_matcher_to_json(value))).collect::>(), + "operation_type": rule.operation_type, + "operation_name": rule.operation_name, + "fields": rule.fields, + }) +} + +fn l7_deny_rule_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "L7DenyRule")?; + Ok(L7DenyRule { + method: string_field(object, "method")?, + path: string_field(object, "path")?, + command: string_field(object, "command")?, + query: query_matcher_map_field(object, "query")?, + operation_type: string_field(object, "operation_type")?, + operation_name: string_field(object, "operation_name")?, + fields: string_array_field(object, "fields")?, + }) +} + +fn l7_query_matcher_to_json(matcher: &L7QueryMatcher) -> JsonValue { + json!({ "glob": matcher.glob, "any": matcher.any }) +} + +fn l7_query_matcher_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "L7QueryMatcher")?; + Ok(L7QueryMatcher { + glob: string_field(object, "glob")?, + any: string_array_field(object, "any")?, + }) +} + +fn query_matcher_map_field( + object: &JsonMap, + field: &str, +) -> Result, Status> { + object + .get(field) + .and_then(JsonValue::as_object) + .map_or_else( + || Ok(HashMap::new()), + |values| { + values + .iter() + .map(|(key, value)| Ok((key.clone(), l7_query_matcher_from_json(value)?))) + .collect() + }, + ) +} + +fn graphql_operation_to_json(operation: &GraphqlOperation) -> JsonValue { + json!({ + "operation_type": operation.operation_type, + "operation_name": operation.operation_name, + "fields": operation.fields, + }) +} + +fn graphql_operation_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "GraphqlOperation")?; + Ok(GraphqlOperation { + operation_type: string_field(object, "operation_type")?, + operation_name: string_field(object, "operation_name")?, + fields: string_array_field(object, "fields")?, + }) +} + +fn provider_profile_import_item_to_json(item: &ProviderProfileImportItem) -> JsonValue { + json!({ + "profile": item.profile.as_ref().map(provider_profile_to_json), + "source": item.source, + }) +} + +fn provider_profile_import_item_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderProfileImportItem")?; + Ok(ProviderProfileImportItem { + profile: optional_object_field(object, "profile")? + .map(provider_profile_from_json) + .transpose()?, + source: string_field(object, "source")?, + }) +} + +fn provider_profile_to_json(profile: &ProviderProfile) -> JsonValue { + json!({ + "id": profile.id, + "display_name": profile.display_name, + "description": profile.description, + "category": profile.category, + "credentials": profile.credentials.iter().map(provider_profile_credential_to_json).collect::>(), + "endpoints": profile.endpoints.iter().map(network_endpoint_to_json).collect::>(), + "binaries": profile.binaries.iter().map(network_binary_to_json).collect::>(), + "inference_capable": profile.inference_capable, + "discovery": profile.discovery.as_ref().map(provider_profile_discovery_to_json), + }) +} + +fn provider_profile_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "ProviderProfile")?; + Ok(ProviderProfile { + id: string_field(object, "id")?, + display_name: string_field(object, "display_name")?, + description: string_field(object, "description")?, + category: i32_field(object, "category")?, + credentials: array_field(object, "credentials")? + .iter() + .map(provider_profile_credential_from_json) + .collect::>()?, + endpoints: array_field(object, "endpoints")? + .iter() + .map(network_endpoint_from_json) + .collect::>()?, + binaries: array_field(object, "binaries")? + .iter() + .map(network_binary_from_json) + .collect::>()?, + inference_capable: bool_field(object, "inference_capable")?, + discovery: optional_object_field(object, "discovery")? + .map(provider_profile_discovery_from_json) + .transpose()?, + }) +} + +fn provider_profile_credential_to_json( + credential: &openshell_core::proto::ProviderProfileCredential, +) -> JsonValue { + json!({ + "name": credential.name, + "description": credential.description, + "env_vars": credential.env_vars, + "required": credential.required, + "auth_style": credential.auth_style, + "header_name": credential.header_name, + "query_param": credential.query_param, + "refresh": credential.refresh.as_ref().map(provider_credential_refresh_to_json), + "path_template": credential.path_template, + "token_grant": credential.token_grant.as_ref().map(provider_credential_token_grant_to_json), + }) +} + +fn provider_profile_credential_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderProfileCredential")?; + Ok(openshell_core::proto::ProviderProfileCredential { + name: string_field(object, "name")?, + description: string_field(object, "description")?, + env_vars: string_array_field(object, "env_vars")?, + required: bool_field(object, "required")?, + auth_style: string_field(object, "auth_style")?, + header_name: string_field(object, "header_name")?, + query_param: string_field(object, "query_param")?, + refresh: optional_object_field(object, "refresh")? + .map(provider_credential_refresh_from_json) + .transpose()?, + path_template: string_field(object, "path_template")?, + token_grant: optional_object_field(object, "token_grant")? + .map(provider_credential_token_grant_from_json) + .transpose()?, + }) +} + +fn provider_credential_refresh_to_json(refresh: &ProviderCredentialRefresh) -> JsonValue { + json!({ + "strategy": refresh.strategy, + "token_url": refresh.token_url, + "scopes": refresh.scopes, + "refresh_before_seconds": refresh.refresh_before_seconds, + "max_lifetime_seconds": refresh.max_lifetime_seconds, + "material": refresh.material.iter().map(provider_credential_refresh_material_to_json).collect::>(), + }) +} + +fn provider_credential_refresh_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderCredentialRefresh")?; + Ok(ProviderCredentialRefresh { + strategy: i32_field(object, "strategy")?, + token_url: string_field(object, "token_url")?, + scopes: string_array_field(object, "scopes")?, + refresh_before_seconds: i64_field(object, "refresh_before_seconds")?, + max_lifetime_seconds: i64_field(object, "max_lifetime_seconds")?, + material: array_field(object, "material")? + .iter() + .map(provider_credential_refresh_material_from_json) + .collect::>()?, + }) +} + +fn provider_credential_refresh_material_to_json( + material: &ProviderCredentialRefreshMaterial, +) -> JsonValue { + json!({ + "name": material.name, + "description": material.description, + "required": material.required, + "secret": material.secret, + }) +} + +fn provider_credential_refresh_material_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderCredentialRefreshMaterial")?; + Ok(ProviderCredentialRefreshMaterial { + name: string_field(object, "name")?, + description: string_field(object, "description")?, + required: bool_field(object, "required")?, + secret: bool_field(object, "secret")?, + }) +} + +fn provider_credential_token_grant_to_json(grant: &ProviderCredentialTokenGrant) -> JsonValue { + json!({ + "token_endpoint": grant.token_endpoint, + "audience": grant.audience, + "jwt_svid_audience": grant.jwt_svid_audience, + "scopes": grant.scopes, + "cache_ttl_seconds": grant.cache_ttl_seconds, + "audience_overrides": grant.audience_overrides.iter().map(provider_credential_token_grant_override_to_json).collect::>(), + "client_assertion_type": grant.client_assertion_type, + }) +} + +fn provider_credential_token_grant_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderCredentialTokenGrant")?; + Ok(ProviderCredentialTokenGrant { + token_endpoint: string_field(object, "token_endpoint")?, + audience: string_field(object, "audience")?, + jwt_svid_audience: string_field(object, "jwt_svid_audience")?, + scopes: string_array_field(object, "scopes")?, + cache_ttl_seconds: i64_field(object, "cache_ttl_seconds")?, + audience_overrides: array_field(object, "audience_overrides")? + .iter() + .map(provider_credential_token_grant_override_from_json) + .collect::>()?, + client_assertion_type: string_field(object, "client_assertion_type")?, + }) +} + +fn provider_credential_token_grant_override_to_json( + override_config: &ProviderCredentialTokenGrantAudienceOverride, +) -> JsonValue { + json!({ + "host": override_config.host, + "port": override_config.port, + "path": override_config.path, + "audience": override_config.audience, + "scopes": override_config.scopes, + }) +} + +fn provider_credential_token_grant_override_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderCredentialTokenGrantAudienceOverride")?; + Ok(ProviderCredentialTokenGrantAudienceOverride { + host: string_field(object, "host")?, + port: u32_field(object, "port")?, + path: string_field(object, "path")?, + audience: string_field(object, "audience")?, + scopes: string_array_field(object, "scopes")?, + }) +} + +fn provider_profile_discovery_to_json(discovery: &ProviderProfileDiscovery) -> JsonValue { + json!({ "credentials": discovery.credentials }) +} + +fn provider_profile_discovery_from_json( + value: &JsonValue, +) -> Result { + let object = expect_object(value, "ProviderProfileDiscovery")?; + Ok(ProviderProfileDiscovery { + credentials: string_array_field(object, "credentials")?, + }) +} + +fn setting_value_to_json(value: &SettingValue) -> JsonValue { + match value.value.as_ref() { + Some(setting_value::Value::StringValue(value)) => { + json!({ "string_value": value }) + } + Some(setting_value::Value::BoolValue(value)) => { + json!({ "bool_value": value }) + } + Some(setting_value::Value::IntValue(value)) => { + json!({ "int_value": value }) + } + Some(setting_value::Value::BytesValue(value)) => { + json!({ "bytes_value": base64::engine::general_purpose::STANDARD.encode(value) }) + } + None => JsonValue::Object(JsonMap::new()), + } +} + +fn setting_value_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "SettingValue")?; + let value = if let Some(value) = object.get("string_value") { + Some(setting_value::Value::StringValue( + value.as_str().unwrap_or_default().to_string(), + )) + } else if let Some(value) = object.get("bool_value") { + Some(setting_value::Value::BoolValue( + value.as_bool().unwrap_or_default(), + )) + } else if let Some(value) = object.get("int_value") { + Some(setting_value::Value::IntValue(json_i64( + value, + "int_value", + )?)) + } else if let Some(value) = object.get("bytes_value") { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.as_str().unwrap_or_default()) + .map_err(|err| Status::invalid_argument(format!("invalid bytes_value: {err}")))?; + Some(setting_value::Value::BytesValue(bytes)) + } else { + None + }; + Ok(SettingValue { value }) +} + +fn policy_merge_operation_to_json(operation: &PolicyMergeOperation) -> JsonValue { + match operation.operation.as_ref() { + Some(policy_merge_operation::Operation::AddRule(value)) => { + json!({ "add_rule": add_network_rule_to_json(value) }) + } + Some(policy_merge_operation::Operation::RemoveEndpoint(value)) => { + json!({ "remove_endpoint": remove_network_endpoint_to_json(value) }) + } + Some(policy_merge_operation::Operation::RemoveRule(value)) => { + json!({ "remove_rule": remove_network_rule_to_json(value) }) + } + Some(policy_merge_operation::Operation::AddDenyRules(value)) => { + json!({ "add_deny_rules": add_deny_rules_to_json(value) }) + } + Some(policy_merge_operation::Operation::AddAllowRules(value)) => { + json!({ "add_allow_rules": add_allow_rules_to_json(value) }) + } + Some(policy_merge_operation::Operation::RemoveBinary(value)) => { + json!({ "remove_binary": remove_network_binary_to_json(value) }) + } + None => JsonValue::Object(JsonMap::new()), + } +} + +fn policy_merge_operation_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "PolicyMergeOperation")?; + let operation = if let Some(value) = object.get("add_rule") { + Some(policy_merge_operation::Operation::AddRule( + add_network_rule_from_json(value)?, + )) + } else if let Some(value) = object.get("remove_endpoint") { + Some(policy_merge_operation::Operation::RemoveEndpoint( + remove_network_endpoint_from_json(value)?, + )) + } else if let Some(value) = object.get("remove_rule") { + Some(policy_merge_operation::Operation::RemoveRule( + remove_network_rule_from_json(value)?, + )) + } else if let Some(value) = object.get("add_deny_rules") { + Some(policy_merge_operation::Operation::AddDenyRules( + add_deny_rules_from_json(value)?, + )) + } else if let Some(value) = object.get("add_allow_rules") { + Some(policy_merge_operation::Operation::AddAllowRules( + add_allow_rules_from_json(value)?, + )) + } else if let Some(value) = object.get("remove_binary") { + Some(policy_merge_operation::Operation::RemoveBinary( + remove_network_binary_from_json(value)?, + )) + } else { + None + }; + Ok(PolicyMergeOperation { operation }) +} + +fn add_network_rule_to_json(value: &AddNetworkRule) -> JsonValue { + json!({ "rule_name": value.rule_name, "rule": value.rule.as_ref().map(network_policy_rule_to_json) }) +} + +fn add_network_rule_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "AddNetworkRule")?; + Ok(AddNetworkRule { + rule_name: string_field(object, "rule_name")?, + rule: optional_object_field(object, "rule")? + .map(network_policy_rule_from_json) + .transpose()?, + }) +} + +fn remove_network_endpoint_to_json(value: &RemoveNetworkEndpoint) -> JsonValue { + json!({ "rule_name": value.rule_name, "host": value.host, "port": value.port }) +} + +fn remove_network_endpoint_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "RemoveNetworkEndpoint")?; + Ok(RemoveNetworkEndpoint { + rule_name: string_field(object, "rule_name")?, + host: string_field(object, "host")?, + port: u32_field(object, "port")?, + }) +} + +fn remove_network_rule_to_json(value: &RemoveNetworkRule) -> JsonValue { + json!({ "rule_name": value.rule_name }) +} + +fn remove_network_rule_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "RemoveNetworkRule")?; + Ok(RemoveNetworkRule { + rule_name: string_field(object, "rule_name")?, + }) +} + +fn add_deny_rules_to_json(value: &AddDenyRules) -> JsonValue { + json!({ + "host": value.host, + "port": value.port, + "deny_rules": value.deny_rules.iter().map(l7_deny_rule_to_json).collect::>(), + }) +} + +fn add_deny_rules_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "AddDenyRules")?; + Ok(AddDenyRules { + host: string_field(object, "host")?, + port: u32_field(object, "port")?, + deny_rules: array_field(object, "deny_rules")? + .iter() + .map(l7_deny_rule_from_json) + .collect::>()?, + }) +} + +fn add_allow_rules_to_json(value: &AddAllowRules) -> JsonValue { + json!({ + "host": value.host, + "port": value.port, + "rules": value.rules.iter().map(l7_rule_to_json).collect::>(), + }) +} + +fn add_allow_rules_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "AddAllowRules")?; + Ok(AddAllowRules { + host: string_field(object, "host")?, + port: u32_field(object, "port")?, + rules: array_field(object, "rules")? + .iter() + .map(l7_rule_from_json) + .collect::>()?, + }) +} + +fn remove_network_binary_to_json(value: &RemoveNetworkBinary) -> JsonValue { + json!({ "rule_name": value.rule_name, "binary_path": value.binary_path }) +} + +fn remove_network_binary_from_json(value: &JsonValue) -> Result { + let object = expect_object(value, "RemoveNetworkBinary")?; + Ok(RemoveNetworkBinary { + rule_name: string_field(object, "rule_name")?, + binary_path: string_field(object, "binary_path")?, + }) +} + +fn driver_sandbox_spec_to_json(spec: &DriverSandboxSpec) -> JsonValue { + json!({ + "log_level": spec.log_level, + "environment": spec.environment, + "template": spec.template.as_ref().map(driver_sandbox_template_to_json), + "gpu": spec.gpu, + "sandbox_token": if spec.sandbox_token.is_empty() { "" } else { "REDACTED" }, + }) +} + +fn driver_sandbox_template_to_json(template: &DriverSandboxTemplate) -> JsonValue { + json!({ + "image": template.image, + "agent_socket_path": template.agent_socket_path, + "labels": template.labels, + "environment": template.environment, + "resources": template.resources.as_ref().map(driver_resource_requirements_to_json), + "platform_config": template.platform_config.as_ref().map(struct_to_json), + "driver_config": template.driver_config.as_ref().map(struct_to_json), + }) +} + +fn driver_resource_requirements_to_json(resources: &DriverResourceRequirements) -> JsonValue { + json!({ + "cpu_request": resources.cpu_request, + "cpu_limit": resources.cpu_limit, + "memory_request": resources.memory_request, + "memory_limit": resources.memory_limit, + }) +} + +fn driver_sandbox_status_to_json(status: &DriverSandboxStatus) -> JsonValue { + json!({ + "sandbox_name": status.sandbox_name, + "instance_id": status.instance_id, + "agent_fd": status.agent_fd, + "sandbox_fd": status.sandbox_fd, + "conditions": status.conditions.iter().map(|condition| json!({ + "type": condition.r#type, + "status": condition.status, + "reason": condition.reason, + "message": condition.message, + "last_transition_time": condition.last_transition_time, + })).collect::>(), + "deleting": status.deleting, + }) +} + +fn expect_object<'a>( + value: &'a JsonValue, + type_name: &str, +) -> Result<&'a JsonMap, Status> { + value + .as_object() + .ok_or_else(|| Status::invalid_argument(format!("{type_name} must be a JSON object"))) +} + +fn optional_object_field<'a>( + object: &'a JsonMap, + field: &str, +) -> Result, Status> { + match object.get(field) { + Some(JsonValue::Null) | None => Ok(None), + Some(value) if value.is_object() => Ok(Some(value)), + Some(_) => Err(Status::invalid_argument(format!( + "{field} must be an object" + ))), + } +} + +fn optional_json_field<'a>( + object: &'a JsonMap, + field: &str, +) -> Option<&'a JsonValue> { + match object.get(field) { + Some(JsonValue::Null) | None => None, + Some(value) => Some(value), + } +} + +fn string_field(object: &JsonMap, field: &str) -> Result { + Ok(object + .get(field) + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string()) +} + +fn bool_field(object: &JsonMap, field: &str) -> Result { + Ok(object + .get(field) + .and_then(JsonValue::as_bool) + .unwrap_or_default()) +} + +fn optional_bool_field( + object: &JsonMap, + field: &str, +) -> Result, Status> { + Ok(match object.get(field) { + Some(JsonValue::Null) | None => None, + Some(value) => Some(value.as_bool().ok_or_else(|| { + Status::invalid_argument(format!("{field} must be a boolean or null")) + })?), + }) +} + +fn i32_field(object: &JsonMap, field: &str) -> Result { + let value = i64_field(object, field)?; + i32::try_from(value).map_err(|_| Status::invalid_argument(format!("{field} is out of range"))) +} + +fn u32_field(object: &JsonMap, field: &str) -> Result { + let value = u64_field(object, field)?; + u32::try_from(value).map_err(|_| Status::invalid_argument(format!("{field} is out of range"))) +} + +fn u64_field(object: &JsonMap, field: &str) -> Result { + object + .get(field) + .map_or(Ok(0), |value| json_u64(value, field)) +} + +fn i64_field(object: &JsonMap, field: &str) -> Result { + object + .get(field) + .map_or(Ok(0), |value| json_i64(value, field)) +} + +fn json_u64(value: &JsonValue, field: &str) -> Result { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| { + value + .as_f64() + .and_then(|value| exact_unsigned_integer_float(value)) + }) + .ok_or_else(|| Status::invalid_argument(format!("{field} must be an unsigned integer"))) +} + +fn json_i64(value: &JsonValue, field: &str) -> Result { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| { + value + .as_f64() + .and_then(|value| exact_signed_integer_float(value)) + }) + .ok_or_else(|| Status::invalid_argument(format!("{field} must be an integer"))) +} + +fn exact_unsigned_integer_float(value: f64) -> Option { + const MAX_EXACT_INTEGER: f64 = 9_007_199_254_740_991.0; + if value.is_finite() && value >= 0.0 && value <= MAX_EXACT_INTEGER && value.fract() == 0.0 { + Some(value as u64) + } else { + None + } +} + +fn exact_signed_integer_float(value: f64) -> Option { + const MIN_EXACT_INTEGER: f64 = -9_007_199_254_740_991.0; + const MAX_EXACT_INTEGER: f64 = 9_007_199_254_740_991.0; + if value.is_finite() + && (MIN_EXACT_INTEGER..=MAX_EXACT_INTEGER).contains(&value) + && value.fract() == 0.0 + { + Some(value as i64) + } else { + None + } +} + +fn array_field<'a>( + object: &'a JsonMap, + field: &str, +) -> Result<&'a Vec, Status> { + match object.get(field) { + Some(JsonValue::Array(values)) => Ok(values), + Some(JsonValue::Null) | None => Ok(empty_array()), + Some(_) => Err(Status::invalid_argument(format!( + "{field} must be an array" + ))), + } +} + +fn empty_array() -> &'static Vec { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + EMPTY.get_or_init(Vec::new) +} + +fn string_array_field( + object: &JsonMap, + field: &str, +) -> Result, Status> { + array_field(object, field)? + .iter() + .map(|value| { + value + .as_str() + .map(ToString::to_string) + .ok_or_else(|| Status::invalid_argument(format!("{field} entries must be strings"))) + }) + .collect() +} + +fn u32_array_field(object: &JsonMap, field: &str) -> Result, Status> { + array_field(object, field)? + .iter() + .map(|value| { + let value = json_u64(value, field)?; + u32::try_from(value) + .map_err(|_| Status::invalid_argument(format!("{field} entry is out of range"))) + }) + .collect() +} + +fn string_map_field( + object: &JsonMap, + field: &str, +) -> Result, Status> { + Ok(object + .get(field) + .and_then(JsonValue::as_object) + .map_or_else(HashMap::new, json_string_map)) +} + +fn json_string_map(object: &JsonMap) -> HashMap { + object + .iter() + .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) + .collect() +} + +fn i64_map_field( + object: &JsonMap, + field: &str, +) -> Result, Status> { + object + .get(field) + .and_then(JsonValue::as_object) + .map_or_else( + || Ok(HashMap::new()), + |values| { + values + .iter() + .map(|(key, value)| Ok((key.clone(), json_i64(value, field)?))) + .collect() + }, + ) +} + +fn json_to_struct_status(value: &JsonValue) -> Result { + json_to_struct(value) + .map_err(|err| Status::invalid_argument(format!("invalid protobuf Struct JSON: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_policy_from_json_accepts_protobuf_struct_integer_floats() { + let policy = sandbox_policy_from_json(&json!({ + "version": 1.0, + "network_policies": {}, + })) + .expect("policy adapter should accept exact integer-valued floats"); + + assert_eq!(policy.version, 1); + } + + #[test] + fn json_integer_fields_reject_fractional_floats() { + let err = json_u64(&json!(1.5), "version").expect_err("fractional value must fail"); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("unsigned integer")); + } +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 9f1127d0e..553916565 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -28,6 +28,7 @@ mod defaults; mod grpc; mod http; mod inference; +pub(crate) mod interceptors; mod multiplex; mod persistence; pub(crate) mod policy_store; @@ -45,6 +46,7 @@ mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_interceptors::InterceptorRuntime; use std::collections::HashMap; use std::io::ErrorKind; use std::net::SocketAddr; @@ -135,6 +137,10 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + + /// Operation interceptor execution runtime. Empty when no interceptors are + /// configured. + pub(crate) interceptors: Arc, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -185,6 +191,7 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + interceptors: Arc::new(InterceptorRuntime::empty()), } } } @@ -243,6 +250,19 @@ pub async fn run_server( supervisor_sessions.clone(), ) .await?; + let interceptor_configs = config_file.as_ref().map_or(&[][..], |file| { + file.openshell.gateway.interceptors.as_slice() + }); + let interceptor_runtime = Arc::new( + InterceptorRuntime::from_config(interceptor_configs) + .await + .map_err(|err| Error::config(format!("gateway interceptor setup failed: {err}")))?, + ); + if interceptor_runtime.is_empty() { + info!("Gateway interceptors disabled"); + } else { + info!("Gateway interceptors enabled"); + } let mut state = ServerState::new( config.clone(), store.clone(), @@ -253,6 +273,7 @@ pub async fn run_server( supervisor_sessions, oidc_cache, ); + state.interceptors = interceptor_runtime; // Load the gateway-minted sandbox JWT signing key when configured. // Optional so single-driver dev deployments without certgen continue diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ff4542136..077756249 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -132,6 +132,23 @@ roles_claim = "realm_access.roles" admin_role = "openshell-admin" user_role = "openshell-user" scopes_claim = "" + +[[openshell.gateway.interceptors]] +name = "org-controls" +endpoint = "unix:///run/openshell/interceptors/org-controls.sock" +order = 100 +timeout = "750ms" +failure_policy = "fail_closed" + +[openshell.gateway.interceptors.config] +org_id = "example-org" + +[[openshell.gateway.interceptors.overrides]] +binding_id = "sandbox-names" +phases = ["modify_object", "validate_object"] + +[openshell.gateway.interceptors.overrides.match] +labels = { "openshell.dev/team" = "platform" } ``` 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. @@ -142,6 +159,24 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `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. +## Gateway Interceptors + +Use `[[openshell.gateway.interceptors]]` to call external gRPC services before or after selected gateway write operations. When the array is absent or empty, gateway interceptors are disabled. The gateway calls each configured service's `Describe` RPC during startup and refuses to start if the endpoint is unreachable or returns an invalid manifest. + +| Field | Description | +|---|---| +| `name` | Stable configured service name. Must be unique. | +| `endpoint` | Interceptor service endpoint. Use `unix:///path/to/socket`, `grpc://host:port`, or `grpcs://host:port`. Plaintext `grpc://` endpoints should be loopback-local. | +| `order` | Service-level ordering key. Lower values run first; binding order and names break ties. | +| `timeout` | Per-review timeout such as `500ms` or `2s`. Defaults to `2s`. | +| `failure_policy` | Service-wide default: `fail_closed`, `fail_open`, or `ignore`. `ignore` is valid only for `post_commit` bindings. | +| `config` | Operator-defined TOML table passed to `Describe`. | +| `overrides` | Optional binding narrowing by `binding_id`. Overrides can reduce phases, operations, resources, labels, or driver matches declared by the service manifest. | + +Interceptors can inspect sandbox lifecycle, provider/profile writes, and policy/config updates. Modification phases can return JSON Patch operations for safe round-trip resource adapters; validation phases must not return patches. Provider credential values are redacted in review payloads, and patches that target provider credential fields are rejected. + +See [Gateway Interceptors](/reference/gateway-interceptors) for the service protocol, phases, failure handling, and observability behavior. + ## Driver References Each example is a complete TOML file for one compute driver. The examples repeat `[openshell]` and `[openshell.gateway]` so they stay copyable, and the driver tables list the accepted driver-specific keys. Driver-specific values override inherited gateway defaults. The gateway rejects unknown driver fields after inheritance is merged. diff --git a/docs/reference/gateway-interceptors.mdx b/docs/reference/gateway-interceptors.mdx new file mode 100644 index 000000000..b67fe074d --- /dev/null +++ b/docs/reference/gateway-interceptors.mdx @@ -0,0 +1,114 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Gateway Interceptors" +sidebar-title: "Interceptors" +description: "Configure external gateway operation interceptors for sandbox, provider, and policy writes." +keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Gateway, Interceptors, Policy, Reference" +position: 6 +--- + +Gateway interceptors let operators run external gRPC services around selected gateway write operations. Use them for organization policy checks, request normalization, and audit hooks that must run before the gateway persists state or asks a compute driver to create a sandbox. + +Interceptors are disabled unless `[[openshell.gateway.interceptors]]` entries exist in the gateway TOML file. + +## Configuration + +```toml +[[openshell.gateway.interceptors]] +name = "org-controls" +endpoint = "unix:///run/openshell/interceptors/org-controls.sock" +order = 100 +timeout = "750ms" +failure_policy = "fail_closed" + +[openshell.gateway.interceptors.config] +org_id = "example-org" + +[[openshell.gateway.interceptors.overrides]] +binding_id = "sandbox-names" +phases = ["modify_object", "validate_object"] + +[openshell.gateway.interceptors.overrides.match] +labels = { "openshell.dev/team" = "platform" } +``` + +Each configured interceptor service must implement `openshell.interceptor.v1.GatewayInterceptor`. + +| RPC | Purpose | +|---|---| +| `Describe(InterceptorDescribeRequest) -> InterceptorManifest` | Runs once at gateway startup. Returns supported bindings. | +| `Review(InterceptorReview) -> InterceptorDecision` | Runs during matched operation phases. Allows, denies, or patches the reviewed object. | + +The gateway refuses to start if `Describe` fails, if a manifest uses an unsupported API version, or if configuration overrides expand beyond what the manifest declares. + +## Endpoints + +| Scheme | Use | +|---|---| +| `unix://` | Unix domain socket on the gateway host or pod filesystem. | +| `grpc://` | Plaintext gRPC over TCP. Use loopback endpoints for plaintext services. | +| `grpcs://` | gRPC over TLS with native root certificates. | + +Plaintext non-loopback TCP endpoints emit a warning because review payloads can include operational metadata. + +## Bindings + +The service manifest returns bindings. Each binding declares the phases, resources, operations, selector, order, and failure policy it supports. The gateway then narrows those bindings with TOML overrides and computes a deterministic total order. + +Supported resources currently include: + +| Resource | Operations | +|---|---| +| `sandbox` | `create`, `attach_provider`, `detach_provider` | +| `driver_sandbox` | `validate` | +| `provider` | `create`, `update`, `delete` | +| `provider_profile` | `import`, `delete` | +| `config` | `update`, `delete`, `merge` | + +Bindings can match labels and compute driver names. Label selectors only match labels present on the reviewed object or request payload. + +## Phases + +| Phase | When it runs | +|---|---| +| `pre_request` | After authentication and basic request checks, before converting the request into a persisted object. | +| `modify_object` | After the gateway builds an object that has a safe JSON/protobuf round trip. This phase can return JSON Patch operations. | +| `validate_object` | Before persistence. This phase can deny but cannot patch. | +| `validate_driver` | Before compute driver validation for sandbox creates, with the driver-facing sandbox shape. | +| `post_commit` | After successful persistence or provisioning. Failures are ignored by default. | + +Validation phases that return patches are protocol errors. The gateway applies patches only when the phase supports modification and the target resource has an explicit adapter. + +## Decisions + +`Review` returns an `InterceptorDecision`. + +| Field | Behavior | +|---|---| +| `allowed` | `false` denies the operation. | +| `reason` | Operator-visible denial or audit reason. | +| `status_code` | Canonical gRPC code name for denials. Defaults to `permission_denied`. | +| `patches` | JSON Patch operations for modification phases. | +| `warnings` | Logged for operators. | +| `audit_annotations` | Logged with the interceptor decision. | + +Provider credential values are redacted before they are sent to interceptors. The gateway rejects patches whose `path` or `from` targets provider credential fields. + +For sandbox creation, user-supplied request labels are validated with Kubernetes label-value limits before interceptors run. Interceptor patches that add sandbox metadata labels are treated as trusted gateway metadata and may use longer alphanumeric, `-`, `_`, and `.` values for stored attestations such as signatures. Compute driver labels remain separate and should still use platform-compatible values. + +## Failure Policies + +| Policy | Behavior | +|---|---| +| `fail_closed` | Abort the gateway operation on interceptor failures or invalid patches. This is the default for modifying and validation phases. | +| `fail_open` | Log the failure and continue without applying invalid patches. | +| `ignore` | Continue after failures. Valid only for `post_commit`; this is the default for that phase. | + +Denials are not failure-policy errors. A denial always aborts the operation with the selected gRPC status code. + +## Observability + +The gateway emits structured tracing for every interceptor decision with service name, binding ID, phase, resource, operation, decision, latency, and failure policy. Denials and security-relevant interceptor failures also emit gateway OCSF finding events. + +Metrics include review counts, latency, decisions, failures, and the failure policy used for failed reviews. diff --git a/examples/policy-governance-interceptor/Cargo.toml b/examples/policy-governance-interceptor/Cargo.toml new file mode 100644 index 000000000..4be2006f8 --- /dev/null +++ b/examples/policy-governance-interceptor/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "policy-governance-interceptor" +description = "Example OpenShell gateway interceptor that enforces a fixed sandbox policy and provider set" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-interceptors = { path = "../../crates/openshell-interceptors" } +openshell-policy = { path = "../../crates/openshell-policy" } + +prost-types = { workspace = true } +jsonwebtoken = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +json-patch = "1.4" + +[lints] +workspace = true diff --git a/examples/policy-governance-interceptor/README.md b/examples/policy-governance-interceptor/README.md new file mode 100644 index 000000000..222b6d66a --- /dev/null +++ b/examples/policy-governance-interceptor/README.md @@ -0,0 +1,101 @@ +# Policy Governance Interceptor + +This example implements a gateway interceptor that vends and enforces one source-control governance baseline: + +- every sandbox receives the interceptor-vended [`policy.yaml`](policy.yaml) +- every sandbox attaches exactly two provider records: `github` and `gitlab` +- every sandbox gets a policy signature label +- users cannot attach or detach other providers after sandbox creation +- users cannot replace or merge sandbox policy after sandbox creation +- users cannot create provider records other than `github` and `gitlab` +- users cannot update or delete governed provider records after creation + +The interceptor is intentionally hardcoded so the policy decision is easy to inspect. The gateway does not preload this policy from config or setup commands; it only calls the interceptor. + +## Start the Interceptor + +```shell +cargo run -p policy-governance-interceptor -- 127.0.0.1:18098 +``` + +Configure the gateway to call it: + +```toml +[[openshell.gateway.interceptors]] +name = "policy-governance" +endpoint = "grpc://127.0.0.1:18098" +order = 100 +timeout = "750ms" +failure_policy = "fail_closed" +``` + +Restart the gateway after editing `gateway.toml`. Gateway startup fails if the interceptor is not reachable. + +The TOML does not include a sandbox policy. During sandbox creation, the interceptor patches the sandbox object with its embedded policy and then validates that the final object still matches that policy. + +## Create Governed Providers + +The interceptor can require provider records, but it cannot create or mint credentials. Create the governed provider records before creating sandboxes: + +```shell +openshell provider create \ + --name github \ + --type github \ + --credential GITHUB_TOKEN + +openshell provider create \ + --name gitlab \ + --type gitlab \ + --credential GITLAB_TOKEN +``` + +Then create a sandbox normally, without passing `--policy`: + +```shell +openshell sandbox create --name governed -- claude +``` + +The interceptor patches the create request so the sandbox uses the interceptor-vended policy and the `github` and `gitlab` providers. Requests that include any other provider or a different policy are denied. + +The interceptor signs the SHA-256 digest of its embedded `policy.yaml` into an HS256 JWT at +startup. Before it vends or validates the policy, it verifies that JWT against the embedded +policy bytes, checks the local revocation list, and confirms the cached working copy is still +inside its declared freshness window. The sandbox stores the signed JWT in one metadata label: + +```text +governance.nvidia.com/signature= +``` + +Sandbox creation is denied if the caller tries to set that reserved label to a different value. +Mutation-capable reviews fail closed when the cached artifact is stale, revoked, or no longer +verifies against the embedded policy payload. +The signing key in this example is static and only demonstrates the workflow. A production +interceptor should use managed key material and asymmetric verification. + +## Expected Denials + +These operations fail while the interceptor is enabled: + +```shell +openshell sandbox provider attach governed other-provider +openshell sandbox provider detach governed github +openshell policy set governed --policy custom-policy.yaml +openshell policy update governed --add-endpoint api.example.com:443:read-only:rest:enforce +openshell provider create --name slack --type generic --credential SLACK_TOKEN +openshell provider update github --credential GITHUB_TOKEN=new-token +openshell provider delete github +``` + +Non-policy settings updates are allowed. + +## Smoke Test + +Run the end-to-end smoke test from the repository root: + +```shell +bash examples/policy-governance-interceptor/smoke.sh +``` + +The script builds the gateway, CLI, and interceptor; starts a temporary Docker-backed gateway configured only with the interceptor; and prints one `PASS` or `FAIL` line for each governance case. + +The smoke build defaults to `CC=clang`, `CXX=clang++`, and an empty `RUSTC_WRAPPER` so local `sccache` or Homebrew GCC settings do not break native dependency builds. Override those defaults with `SMOKE_CC`, `SMOKE_CXX`, or `SMOKE_RUSTC_WRAPPER` when needed. diff --git a/examples/policy-governance-interceptor/policy.yaml b/examples/policy-governance-interceptor/policy.yaml new file mode 100644 index 000000000..3c0ff5814 --- /dev/null +++ b/examples/policy-governance-interceptor/policy.yaml @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + github_source_control: + name: github-source-control-readonly + endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + path: /graphql + port: 443 + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + binaries: + - { path: /usr/bin/gh } + - { path: /usr/local/bin/gh } + - { path: /usr/bin/git } + - { path: /usr/local/bin/git } + + gitlab_source_control: + name: gitlab-source-control-readonly + endpoints: + - host: gitlab.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + allow_encoded_slash: true + - host: gitlab.com + path: /api/graphql + port: 443 + protocol: graphql + access: read-only + enforcement: enforce + - host: gitlab.com + path: /api/v4 + port: 443 + protocol: rest + access: read-only + enforcement: enforce + allow_encoded_slash: true + binaries: + - { path: /usr/bin/glab } + - { path: /usr/local/bin/glab } + - { path: /usr/bin/git } + - { path: /usr/local/bin/git } diff --git a/examples/policy-governance-interceptor/smoke.sh b/examples/policy-governance-interceptor/smoke.sh new file mode 100755 index 000000000..21e984fb9 --- /dev/null +++ b/examples/policy-governance-interceptor/smoke.sh @@ -0,0 +1,367 @@ +#!/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 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +GATEWAY_PORT="${GATEWAY_PORT:-18096}" +HEALTH_PORT="${HEALTH_PORT:-18097}" +INTERCEPTOR_HOST="${INTERCEPTOR_HOST:-127.0.0.1}" +INTERCEPTOR_PORT="${INTERCEPTOR_PORT:-18098}" +INTERCEPTOR_ADDR="${INTERCEPTOR_HOST}:${INTERCEPTOR_PORT}" +GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT:-http://127.0.0.1:${GATEWAY_PORT}}" +SANDBOX_NAME="${SANDBOX_NAME:-policy-governance-smoke-$$}" +CUSTOM_SANDBOX_NAME="${CUSTOM_SANDBOX_NAME:-${SANDBOX_NAME}-custom}" +KEEP_SMOKE_ARTIFACTS="${KEEP_SMOKE_ARTIFACTS:-0}" +CARGO_FEATURES="${CARGO_FEATURES:-openshell-server/gh-release-z3,openshell-cli/gh-release-z3}" +SMOKE_CC="${SMOKE_CC:-clang}" +SMOKE_CXX="${SMOKE_CXX:-clang++}" +SMOKE_RUSTC_WRAPPER="${SMOKE_RUSTC_WRAPPER-}" + +SMOKE_TMP_PARENT="${SMOKE_TMP_PARENT:-/tmp}" +TMP_DIR="$(mktemp -d "${SMOKE_TMP_PARENT%/}/ospgi.XXXXXX")" +GATEWAY_CONFIG="${TMP_DIR}/gateway.toml" +GATEWAY_DB="${TMP_DIR}/gateway.db" +PKI_DIR="${TMP_DIR}/pki" +CUSTOM_POLICY="${TMP_DIR}/custom-policy.yaml" +INTERCEPTOR_LOG="${TMP_DIR}/interceptor.log" +GATEWAY_LOG="${TMP_DIR}/gateway.log" + +OPENSHELL_BIN="${REPO_ROOT}/target/debug/openshell" +GATEWAY_BIN="${REPO_ROOT}/target/debug/openshell-gateway" +INTERCEPTOR_BIN="${REPO_ROOT}/target/debug/policy-governance-interceptor" + +INTERCEPTOR_PID="" +GATEWAY_PID="" +FAILED=0 +LAST_OUTPUT="" + +OS=("${OPENSHELL_BIN}" --gateway-endpoint "${GATEWAY_ENDPOINT}") + +cleanup() { + local status=$? + + if [[ -x "${OPENSHELL_BIN}" ]]; then + "${OS[@]}" sandbox delete "${SANDBOX_NAME}" >/dev/null 2>&1 || true + "${OS[@]}" sandbox delete "${CUSTOM_SANDBOX_NAME}" >/dev/null 2>&1 || true + fi + + if [[ -n "${GATEWAY_PID}" ]] && kill -0 "${GATEWAY_PID}" >/dev/null 2>&1; then + kill "${GATEWAY_PID}" >/dev/null 2>&1 || true + wait "${GATEWAY_PID}" 2>/dev/null || true + fi + if [[ -n "${INTERCEPTOR_PID}" ]] && kill -0 "${INTERCEPTOR_PID}" >/dev/null 2>&1; then + kill "${INTERCEPTOR_PID}" >/dev/null 2>&1 || true + wait "${INTERCEPTOR_PID}" 2>/dev/null || true + fi + + if [[ "${KEEP_SMOKE_ARTIFACTS}" == "1" || ${status} -ne 0 ]]; then + printf "smoke artifacts: %s\n" "${TMP_DIR}" >&2 + else + rm -rf "${TMP_DIR}" + fi +} +trap cleanup EXIT + +run_capture() { + local output + if output="$("$@" 2>&1)"; then + LAST_OUTPUT="${output}" + return 0 + fi + local status=$? + LAST_OUTPUT="${output}" + return "${status}" +} + +captured_error_output() { + [[ "${LAST_OUTPUT}" == *"Error:"* || "${LAST_OUTPUT}" == *"× code:"* ]] +} + +expect_success() { + run_capture "$@" || return 1 + ! captured_error_output +} + +expect_failure() { + if run_capture "$@"; then + if captured_error_output; then + return 0 + fi + LAST_OUTPUT="command unexpectedly succeeded: $*"$'\n'"${LAST_OUTPUT}" + return 1 + fi + return 0 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "${haystack}" != *"${needle}"* ]]; then + LAST_OUTPUT="expected output to contain '${needle}'"$'\n'"${haystack}" + return 1 + fi +} + +run_case() { + local name="$1" + shift + + LAST_OUTPUT="" + if "$@"; then + printf "PASS %s\n" "${name}" + else + printf "FAIL %s\n" "${name}" + if [[ -n "${LAST_OUTPUT}" ]]; then + printf "%s\n" "${LAST_OUTPUT}" | sed 's/^/ /' + fi + FAILED=1 + fi +} + +wait_for_interceptor() { + for _ in $(seq 1 100); do + if (exec 3<>"/dev/tcp/${INTERCEPTOR_HOST}/${INTERCEPTOR_PORT}") >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "${INTERCEPTOR_PID}" >/dev/null 2>&1; then + printf "interceptor exited early\n" >&2 + sed 's/^/ /' "${INTERCEPTOR_LOG}" >&2 || true + exit 1 + fi + sleep 0.1 + done + + printf "interceptor did not become ready at %s\n" "${INTERCEPTOR_ADDR}" >&2 + sed 's/^/ /' "${INTERCEPTOR_LOG}" >&2 || true + exit 1 +} + +wait_for_gateway() { + for _ in $(seq 1 120); do + if "${OS[@]}" status >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "${GATEWAY_PID}" >/dev/null 2>&1; then + printf "gateway exited early\n" >&2 + sed 's/^/ /' "${GATEWAY_LOG}" >&2 || true + exit 1 + fi + sleep 0.5 + done + + printf "gateway did not become ready at %s\n" "${GATEWAY_ENDPOINT}" >&2 + sed 's/^/ /' "${GATEWAY_LOG}" >&2 || true + exit 1 +} + +write_gateway_config() { + cat > "${GATEWAY_CONFIG}" < "${CUSTOM_POLICY}" <<'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: + custom_example: + name: custom-example + endpoints: + - host: example.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + binaries: + - { path: /usr/bin/curl } +EOF +} + +build_binaries() { + local build_cmd=( + cargo build + -p openshell-server + -p openshell-cli + -p policy-governance-interceptor + ) + if [[ -n "${CARGO_FEATURES}" ]]; then + build_cmd+=(--features "${CARGO_FEATURES}") + fi + + printf "Building smoke binaries...\n" + ( + cd "${REPO_ROOT}" + CC="${SMOKE_CC}" \ + CXX="${SMOKE_CXX}" \ + RUSTC_WRAPPER="${SMOKE_RUSTC_WRAPPER}" \ + "${build_cmd[@]}" + ) +} + +generate_jwt_bundle() { + XDG_CONFIG_HOME="${TMP_DIR}/xdg-config" \ + "${GATEWAY_BIN}" generate-certs \ + --output-dir "${PKI_DIR}" \ + --server-san 127.0.0.1 >/dev/null +} + +start_interceptor() { + "${INTERCEPTOR_BIN}" "${INTERCEPTOR_ADDR}" >"${INTERCEPTOR_LOG}" 2>&1 & + INTERCEPTOR_PID=$! + wait_for_interceptor +} + +start_gateway() { + ( + unset OPENSHELL_GATEWAY_CONFIG + unset OPENSHELL_BIND_ADDRESS + unset OPENSHELL_SERVER_PORT + unset OPENSHELL_HEALTH_PORT + unset OPENSHELL_METRICS_PORT + unset OPENSHELL_DISABLE_TLS + unset OPENSHELL_DRIVERS + unset OPENSHELL_TLS_CERT + unset OPENSHELL_TLS_KEY + unset OPENSHELL_TLS_CLIENT_CA + unset OPENSHELL_ENABLE_MTLS_AUTH + unset OPENSHELL_OIDC_ISSUER + export OPENSHELL_DB_URL="sqlite:${GATEWAY_DB}?mode=rwc" + exec "${GATEWAY_BIN}" --config "${GATEWAY_CONFIG}" + ) >"${GATEWAY_LOG}" 2>&1 & + GATEWAY_PID=$! + wait_for_gateway +} + +create_governed_providers() { + "${OS[@]}" provider create \ + --name github \ + --type github \ + --credential GITHUB_TOKEN=openshell-smoke-github >/dev/null + "${OS[@]}" provider create \ + --name gitlab \ + --type gitlab \ + --credential GITLAB_TOKEN=openshell-smoke-gitlab >/dev/null +} + +test_interceptor_vended_policy() { + expect_success "${OS[@]}" sandbox create \ + --name "${SANDBOX_NAME}" \ + --keep \ + --no-auto-providers \ + --no-tty \ + -- echo "sandbox ready" || return 1 + + expect_success "${OS[@]}" sandbox get "${SANDBOX_NAME}" --policy-only || return 1 + local policy="${LAST_OUTPUT}" + assert_contains "${policy}" "github-source-control-readonly" || return 1 + assert_contains "${policy}" "gitlab-source-control-readonly" || return 1 + assert_contains "${policy}" "api.github.com" || return 1 + assert_contains "${policy}" "gitlab.com" || return 1 + + expect_success "${OS[@]}" sandbox get "${SANDBOX_NAME}" || return 1 + local sandbox="${LAST_OUTPUT}" + assert_contains "${sandbox}" "governance.nvidia.com/signature: eyJ" || return 1 +} + +test_existing_policy_cannot_change() { + expect_failure "${OS[@]}" policy update "${SANDBOX_NAME}" \ + --add-endpoint example.com:443:read-only:rest:enforce \ + --wait \ + --timeout 20 || return 1 + expect_failure "${OS[@]}" policy set "${SANDBOX_NAME}" \ + --policy "${CUSTOM_POLICY}" \ + --wait \ + --timeout 20 +} + +test_custom_policy_create_denied() { + expect_failure "${OS[@]}" sandbox create \ + --name "${CUSTOM_SANDBOX_NAME}" \ + --policy "${CUSTOM_POLICY}" \ + --no-auto-providers \ + --no-tty \ + -- echo "should not run" +} + +test_provider_attach_detach_locked() { + expect_failure "${OS[@]}" sandbox provider attach "${SANDBOX_NAME}" github || return 1 + expect_failure "${OS[@]}" sandbox provider detach "${SANDBOX_NAME}" github || return 1 +} + +test_new_provider_create_denied() { + expect_failure "${OS[@]}" provider create \ + --name slack \ + --type generic \ + --credential API_KEY=openshell-smoke-slack +} + +test_provider_modify_denied() { + expect_failure "${OS[@]}" provider update github \ + --credential GITHUB_TOKEN=openshell-smoke-updated +} + +build_binaries +generate_jwt_bundle +write_gateway_config +write_custom_policy +start_interceptor +start_gateway +create_governed_providers + +printf "Smoke endpoint: %s\n" "${GATEWAY_ENDPOINT}" + +run_case "sandboxes receive the interceptor-vended policy" test_interceptor_vended_policy +run_case "existing sandbox policies cannot be changed" test_existing_policy_cannot_change +run_case "sandboxes cannot be created with custom policies" test_custom_policy_create_denied +run_case "only the governed provider set can remain attached" test_provider_attach_detach_locked +run_case "new providers cannot be created" test_new_provider_create_denied +run_case "providers cannot be modified" test_provider_modify_denied + +if [[ ${FAILED} -ne 0 ]]; then + exit 1 +fi + +printf "policy governance interceptor smoke passed\n" diff --git a/examples/policy-governance-interceptor/src/lib.rs b/examples/policy-governance-interceptor/src/lib.rs new file mode 100644 index 000000000..8ef79e316 --- /dev/null +++ b/examples/policy-governance-interceptor/src/lib.rs @@ -0,0 +1,1194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Example gateway interceptor enforcing a fixed source-control governance baseline. + +#![allow(clippy::result_large_err)] + +use std::collections::HashSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use openshell_core::proto::interceptor::v1::gateway_interceptor_server::GatewayInterceptor; +use openshell_core::proto::interceptor::v1::{ + InterceptorBinding, InterceptorDecision, InterceptorDescribeRequest, InterceptorManifest, + InterceptorReview, InterceptorSelector, JsonPatch, +}; +use openshell_core::proto::{ + GraphqlOperation, L7DenyRule, L7QueryMatcher, L7Rule, NetworkEndpoint, NetworkPolicyRule, + SandboxPolicy, +}; +use openshell_interceptors::{ + API_VERSION, PHASE_MODIFY_OBJECT, PHASE_VALIDATE_OBJECT, json_to_proto_value, json_to_struct, + struct_to_json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use sha2::{Digest, Sha256}; +use tonic::{Request, Response, Status}; + +const POLICY_YAML: &str = include_str!("../policy.yaml"); +const PROVIDER_GITHUB: &str = "github"; +const PROVIDER_GITLAB: &str = "gitlab"; +const PROVIDERS: [&str; 2] = [PROVIDER_GITHUB, PROVIDER_GITLAB]; +const LABEL_SIGNATURE: &str = "governance.nvidia.com/signature"; +const SIGNATURE_VERSION: &str = "1"; +const SIGNATURE_VALID_FROM: &str = "2026-01-01"; +const SIGNATURE_EXPIRES_AT: &str = "9999-12-31"; +const SIGNATURE_ISSUER: &str = "policy-governance"; +const SIGNATURE_SUBJECT: &str = "source-control-sandbox-policy"; +const SIGNATURE_NBF: u64 = 1_767_225_600; +const SIGNATURE_EXP: u64 = 253_402_300_799; +const ARTIFACT_FRESHNESS_WINDOW_SECS: u64 = 3600; +const REVOKED_POLICY_DIGESTS: &[&str] = &[]; +const POLICY_SIGNATURE_SECRET: &[u8] = + b"policy-governance-interceptor-example-signing-key-not-for-production"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct PolicySignatureClaims { + iss: String, + sub: String, + nbf: u64, + exp: u64, + version: String, + valid_from: String, + expires_at: String, + issuer: String, + policy_digest: String, +} + +#[derive(Debug, Clone)] +struct RetrievedPolicyArtifact { + policy_yaml: &'static str, + version: String, + issuer: String, + valid_from: String, + expires_at: String, + policy_digest: String, + signature: String, + retrieved_at_unix_secs: u64, + freshness_window_secs: u64, +} + +#[derive(Debug, Clone)] +struct VerifiedPolicyArtifact { + policy: JsonValue, + providers: JsonValue, + policy_digest: String, + version: String, + issuer: String, + signature: String, + policy_signature_label: String, + cache_valid_until_unix_secs: u64, +} + +#[derive(Debug, Clone)] +pub struct GovernanceInterceptor { + verified_policy: VerifiedPolicyArtifact, + revoked_policy_digests: HashSet, +} + +impl GovernanceInterceptor { + pub fn new() -> Result { + let retrieved_at_unix_secs = current_unix_secs()?; + let artifact = retrieve_policy_artifact(retrieved_at_unix_secs)?; + let verified_policy = verify_policy_artifact(artifact)?; + Ok(Self { + verified_policy, + revoked_policy_digests: REVOKED_POLICY_DIGESTS + .iter() + .map(|digest| (*digest).to_string()) + .collect(), + }) + } + + pub fn manifest() -> InterceptorManifest { + InterceptorManifest { + api_version: API_VERSION.to_string(), + bindings: vec![ + binding( + "sandbox-create-defaults", + PHASE_MODIFY_OBJECT, + "sandbox", + "create", + true, + ), + binding( + "sandbox-create-validate", + PHASE_VALIDATE_OBJECT, + "sandbox", + "create", + false, + ), + binding( + "sandbox-provider-attach", + PHASE_VALIDATE_OBJECT, + "sandbox", + "attach_provider", + false, + ), + binding( + "sandbox-provider-detach", + PHASE_VALIDATE_OBJECT, + "sandbox", + "detach_provider", + false, + ), + binding( + "provider-record-create", + PHASE_VALIDATE_OBJECT, + "provider", + "create", + false, + ), + binding( + "provider-record-update", + PHASE_VALIDATE_OBJECT, + "provider", + "update", + false, + ), + binding( + "provider-record-delete", + PHASE_VALIDATE_OBJECT, + "provider", + "delete", + false, + ), + binding( + "provider-profile-import-lockdown", + PHASE_VALIDATE_OBJECT, + "provider_profile", + "import", + false, + ), + binding( + "provider-profile-delete-lockdown", + PHASE_VALIDATE_OBJECT, + "provider_profile", + "delete", + false, + ), + binding( + "policy-config-update-lockdown", + PHASE_VALIDATE_OBJECT, + "config", + "update", + false, + ), + binding( + "policy-config-merge-lockdown", + PHASE_VALIDATE_OBJECT, + "config", + "merge", + false, + ), + binding( + "policy-config-delete-lockdown", + PHASE_VALIDATE_OBJECT, + "config", + "delete", + false, + ), + ], + } + } + + pub fn review_decision(&self, review: InterceptorReview) -> InterceptorDecision { + match ( + review.phase.as_str(), + review.resource.as_str(), + review.operation.as_str(), + ) { + (PHASE_MODIFY_OBJECT, "sandbox", "create") => match self.require_usable_policy() { + Ok(policy) => self.review_sandbox_create_modify(&review, policy), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "sandbox", "create") => match self.require_usable_policy() { + Ok(policy) => self.review_sandbox_create_validate(&review, policy), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "sandbox", "attach_provider" | "detach_provider") => { + match self.require_usable_policy() { + Ok(_) => deny( + "sandbox provider attachments are managed by the governance interceptor", + ), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + } + } + (PHASE_VALIDATE_OBJECT, "provider", "create") => match self.require_usable_policy() { + Ok(_) => review_provider_create(&review), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "provider", "update") => match self.require_usable_policy() { + Ok(_) => deny("governed provider records cannot be modified"), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "provider", "delete") => match self.require_usable_policy() { + Ok(_) => deny("governed provider records cannot be deleted"), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "provider_profile", _) => match self.require_usable_policy() { + Ok(_) => deny("provider profiles are fixed by this governance example"), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + (PHASE_VALIDATE_OBJECT, "config", _) => match self.require_usable_policy() { + Ok(_) => review_config_update(&review), + Err(err) => deny(&format!("policy artifact unavailable: {err}")), + }, + _ => allow(), + } + } + + fn review_sandbox_create_modify( + &self, + review: &InterceptorReview, + policy: &VerifiedPolicyArtifact, + ) -> InterceptorDecision { + let Some(object) = review.object.as_ref().map(struct_to_json) else { + return deny("sandbox create review missing object"); + }; + let Some(spec) = object.get("spec").and_then(JsonValue::as_object) else { + return deny("sandbox create review missing spec"); + }; + + if !providers_are_subset(spec.get("providers")) { + return deny("sandbox create requested providers outside the governed set"); + } + if let Some(requested_policy) = spec.get("policy") + && !requested_policy.is_null() + && requested_policy != &policy.policy + { + return deny("sandbox create requested a non-governed policy"); + } + + let mut patches = Vec::new(); + if spec.get("policy").is_none_or(JsonValue::is_null) { + patches.push(add("/spec/policy", policy.policy.clone())); + } + if !providers_are_exact(spec.get("providers")) { + patches.push(add("/spec/providers", policy.providers.clone())); + } + if let Some(decision) = + add_signature_label_patch(&object, &mut patches, &policy.policy_signature_label) + { + return decision; + } + + allow_with_patches(patches) + } + + fn review_sandbox_create_validate( + &self, + review: &InterceptorReview, + policy: &VerifiedPolicyArtifact, + ) -> InterceptorDecision { + let Some(object) = review.object.as_ref().map(struct_to_json) else { + return deny("sandbox create validation missing object"); + }; + let Some(spec) = object.get("spec").and_then(JsonValue::as_object) else { + return deny("sandbox create validation missing spec"); + }; + + if !providers_are_exact(spec.get("providers")) { + return deny("sandbox must use exactly the governed providers: github, gitlab"); + } + if spec.get("policy") != Some(&policy.policy) { + return deny("sandbox must use the governed policy"); + } + if !signature_label_matches(&object, &policy.policy_signature_label) { + return deny("sandbox must carry the governed policy signature"); + } + + allow() + } + + fn require_usable_policy(&self) -> Result<&VerifiedPolicyArtifact, String> { + let now = current_unix_secs()?; + self.require_usable_policy_at(now) + } + + fn require_usable_policy_at( + &self, + now_unix_secs: u64, + ) -> Result<&VerifiedPolicyArtifact, String> { + let claims = verify_policy_signature(&self.verified_policy.signature) + .map_err(|err| format!("policy signature validation failed: {err}"))?; + if claims.version != self.verified_policy.version { + return Err("signed policy version does not match cached artifact".to_string()); + } + if claims.issuer != self.verified_policy.issuer { + return Err("signed policy issuer does not match cached artifact".to_string()); + } + if claims.policy_digest != self.verified_policy.policy_digest { + return Err("signed policy digest does not match cached artifact".to_string()); + } + if now_unix_secs > self.verified_policy.cache_valid_until_unix_secs { + return Err("cached policy artifact is stale".to_string()); + } + if self + .revoked_policy_digests + .contains(&self.verified_policy.policy_digest) + { + return Err("cached policy artifact is revoked".to_string()); + } + + Ok(&self.verified_policy) + } +} + +#[tonic::async_trait] +impl GatewayInterceptor for GovernanceInterceptor { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Self::manifest())) + } + + async fn review( + &self, + request: Request, + ) -> Result, Status> { + Ok(Response::new(self.review_decision(request.into_inner()))) + } +} + +fn binding( + id: &str, + phase: &str, + resource: &str, + operation: &str, + modifies: bool, +) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + phases: vec![phase.to_string()], + resources: vec![resource.to_string()], + operations: vec![operation.to_string()], + order: 0, + modifies, + default_failure_policy: "fail_closed".to_string(), + selector: Some(InterceptorSelector::default()), + } +} + +fn review_provider_create(review: &InterceptorReview) -> InterceptorDecision { + let Some(object) = review.object.as_ref().map(struct_to_json) else { + return deny("provider review missing object"); + }; + let name = object + .pointer("/metadata/name") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let provider_type = object + .get("type") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + + if (name == PROVIDER_GITHUB && provider_type == PROVIDER_GITHUB) + || (name == PROVIDER_GITLAB && provider_type == PROVIDER_GITLAB) + { + allow() + } else { + deny("only github and gitlab provider records are allowed") + } +} + +fn review_config_update(review: &InterceptorReview) -> InterceptorDecision { + let Some(object) = review.object.as_ref().map(struct_to_json) else { + return deny("config review missing object"); + }; + + let policy_present = object.get("policy").is_some_and(|value| !value.is_null()); + let merge_present = object + .get("merge_operations") + .and_then(JsonValue::as_array) + .is_some_and(|operations| !operations.is_empty()); + let setting_key = object + .get("setting_key") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .trim(); + + if policy_present || merge_present || setting_key == "policy" { + deny("sandbox policy updates are blocked by the governance interceptor") + } else { + allow() + } +} + +fn allow() -> InterceptorDecision { + InterceptorDecision { + allowed: true, + ..Default::default() + } +} + +fn allow_with_patches(patches: Vec) -> InterceptorDecision { + InterceptorDecision { + allowed: true, + patches, + ..Default::default() + } +} + +fn deny(reason: &str) -> InterceptorDecision { + InterceptorDecision { + allowed: false, + reason: reason.to_string(), + status_code: "permission_denied".to_string(), + ..Default::default() + } +} + +fn add(path: &str, value: JsonValue) -> JsonPatch { + JsonPatch { + op: "add".to_string(), + path: path.to_string(), + from: String::new(), + value: Some(json_to_proto_value(&value).expect("canonical policy JSON is valid protobuf")), + } +} + +fn providers_are_subset(value: Option<&JsonValue>) -> bool { + let Some(providers) = value.and_then(JsonValue::as_array) else { + return true; + }; + providers.iter().all(|provider| { + provider + .as_str() + .is_some_and(|name| PROVIDERS.contains(&name)) + }) +} + +fn providers_are_exact(value: Option<&JsonValue>) -> bool { + let Some(providers) = value.and_then(JsonValue::as_array) else { + return false; + }; + if providers.len() != PROVIDERS.len() { + return false; + } + let actual = providers + .iter() + .filter_map(JsonValue::as_str) + .collect::>(); + actual == PROVIDERS.into_iter().collect::>() +} + +fn governed_providers_json() -> JsonValue { + JsonValue::Array( + PROVIDERS + .into_iter() + .map(|provider| JsonValue::String(provider.to_string())) + .collect(), + ) +} + +fn retrieve_policy_artifact( + retrieved_at_unix_secs: u64, +) -> Result { + let policy_digest = short_policy_digest(POLICY_YAML.as_bytes()); + let claims = policy_signature_claims(policy_digest.clone()); + let signature = sign_policy_signature(&claims)?; + Ok(RetrievedPolicyArtifact { + policy_yaml: POLICY_YAML, + version: SIGNATURE_VERSION.to_string(), + issuer: SIGNATURE_ISSUER.to_string(), + valid_from: SIGNATURE_VALID_FROM.to_string(), + expires_at: SIGNATURE_EXPIRES_AT.to_string(), + policy_digest, + signature, + retrieved_at_unix_secs, + freshness_window_secs: ARTIFACT_FRESHNESS_WINDOW_SECS, + }) +} + +fn verify_policy_artifact( + artifact: RetrievedPolicyArtifact, +) -> Result { + let expected_digest = short_policy_digest(artifact.policy_yaml.as_bytes()); + if artifact.policy_digest != expected_digest { + return Err("retrieved policy digest does not match policy payload".to_string()); + } + + let claims = verify_policy_signature(&artifact.signature)?; + if claims.version != artifact.version { + return Err("signed policy version does not match retrieved artifact".to_string()); + } + if claims.issuer != artifact.issuer { + return Err("signed policy issuer does not match retrieved artifact".to_string()); + } + if claims.valid_from != artifact.valid_from { + return Err("signed policy valid_from does not match retrieved artifact".to_string()); + } + if claims.expires_at != artifact.expires_at { + return Err("signed policy expires_at does not match retrieved artifact".to_string()); + } + if claims.policy_digest != artifact.policy_digest { + return Err("signed policy digest does not match retrieved artifact".to_string()); + } + + let policy = openshell_policy::parse_sandbox_policy(artifact.policy_yaml) + .map_err(|err| err.to_string())?; + let cache_valid_until_unix_secs = artifact + .retrieved_at_unix_secs + .checked_add(artifact.freshness_window_secs) + .ok_or_else(|| "policy artifact freshness window overflowed".to_string())?; + Ok(VerifiedPolicyArtifact { + policy: normalize_review_json(&sandbox_policy_to_review_json(&policy))?, + providers: governed_providers_json(), + policy_digest: artifact.policy_digest, + version: artifact.version, + issuer: artifact.issuer, + signature: artifact.signature.clone(), + policy_signature_label: policy_signature_label_value(&artifact.signature)?, + cache_valid_until_unix_secs, + }) +} + +fn policy_signature_claims(policy_digest: String) -> PolicySignatureClaims { + PolicySignatureClaims { + iss: SIGNATURE_ISSUER.to_string(), + sub: SIGNATURE_SUBJECT.to_string(), + nbf: SIGNATURE_NBF, + exp: SIGNATURE_EXP, + version: SIGNATURE_VERSION.to_string(), + valid_from: SIGNATURE_VALID_FROM.to_string(), + expires_at: SIGNATURE_EXPIRES_AT.to_string(), + issuer: SIGNATURE_ISSUER.to_string(), + policy_digest, + } +} + +fn sign_policy_signature(claims: &PolicySignatureClaims) -> Result { + encode( + &Header::new(Algorithm::HS256), + claims, + &EncodingKey::from_secret(POLICY_SIGNATURE_SECRET), + ) + .map_err(|err| err.to_string()) +} + +fn verify_policy_signature(token: &str) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_nbf = true; + validation.set_issuer(&[SIGNATURE_ISSUER]); + validation.set_required_spec_claims(&["exp", "iss", "nbf", "sub"]); + let claims = decode::( + token, + &DecodingKey::from_secret(POLICY_SIGNATURE_SECRET), + &validation, + ) + .map_err(|err| err.to_string())? + .claims; + validate_policy_signature_claims(&claims)?; + Ok(claims) +} + +fn validate_policy_signature_claims(claims: &PolicySignatureClaims) -> Result<(), String> { + let expected = policy_signature_claims(short_policy_digest(POLICY_YAML.as_bytes())); + if claims != &expected { + return Err("policy signature claims do not match vendored policy".to_string()); + } + Ok(()) +} + +fn policy_signature_label_value(token: &str) -> Result { + if token.split('.').count() != 3 { + return Err("policy signature JWT must have three segments".to_string()); + } + Ok(token.to_string()) +} + +fn add_signature_label_patch( + object: &JsonValue, + patches: &mut Vec, + signature: &str, +) -> Option { + match object.pointer("/metadata/labels") { + Some(JsonValue::Object(_)) => {} + Some(JsonValue::Null) | None => { + patches.push(add("/metadata/labels", signature_label_json(signature))); + return None; + } + Some(_) => return Some(deny("sandbox metadata labels must be a JSON object")), + } + + match object.pointer(&signature_label_pointer()) { + Some(existing) if existing.as_str() == Some(signature) => {} + Some(_) => { + return Some(deny(&format!( + "sandbox create requested reserved signature label '{LABEL_SIGNATURE}'" + ))); + } + None => patches.push(add( + &signature_label_pointer(), + JsonValue::String(signature.to_string()), + )), + } + + None +} + +fn signature_label_matches(object: &JsonValue, signature: &str) -> bool { + object + .pointer(&signature_label_pointer()) + .and_then(JsonValue::as_str) + == Some(signature) +} + +fn signature_label_json(signature: &str) -> JsonValue { + JsonValue::Object( + [( + LABEL_SIGNATURE.to_string(), + JsonValue::String(signature.to_string()), + )] + .into_iter() + .collect(), + ) +} + +fn signature_label_pointer() -> String { + format!("/metadata/labels/{}", json_pointer_escape(LABEL_SIGNATURE)) +} + +fn json_pointer_escape(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn short_policy_digest(policy_yaml: &[u8]) -> String { + let digest = Sha256::digest(policy_yaml); + format!("sha256-{}", hex_prefix(&digest, 16)) +} + +fn current_unix_secs() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| err.to_string()) + .map(|duration| duration.as_secs()) +} + +fn hex_prefix(bytes: &[u8], chars: usize) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(chars); + for byte in bytes { + if out.len() == chars { + break; + } + out.push(HEX[(byte >> 4) as usize] as char); + if out.len() == chars { + break; + } + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +fn normalize_review_json(value: &JsonValue) -> Result { + json_to_struct(value).map(|value| struct_to_json(&value)) +} + +fn sandbox_policy_to_review_json(policy: &SandboxPolicy) -> JsonValue { + json!({ + "version": policy.version, + "filesystem": policy.filesystem.as_ref().map(|filesystem| json!({ + "include_workdir": filesystem.include_workdir, + "read_only": filesystem.read_only, + "read_write": filesystem.read_write, + })), + "landlock": policy.landlock.as_ref().map(|landlock| json!({ + "compatibility": landlock.compatibility, + })), + "process": policy.process.as_ref().map(|process| json!({ + "run_as_user": process.run_as_user, + "run_as_group": process.run_as_group, + })), + "network_policies": policy.network_policies.iter().map(|(key, value)| { + (key.clone(), network_policy_rule_to_json(value)) + }).collect::>(), + }) +} + +#[allow(deprecated)] +fn network_policy_rule_to_json(rule: &NetworkPolicyRule) -> JsonValue { + json!({ + "name": rule.name, + "endpoints": rule.endpoints.iter().map(network_endpoint_to_json).collect::>(), + "binaries": rule.binaries.iter().map(|binary| { + json!({ "path": binary.path, "harness": binary.harness }) + }).collect::>(), + }) +} + +#[allow(deprecated)] +fn network_endpoint_to_json(endpoint: &NetworkEndpoint) -> JsonValue { + json!({ + "host": endpoint.host, + "port": endpoint.port, + "protocol": endpoint.protocol, + "tls": endpoint.tls, + "enforcement": endpoint.enforcement, + "access": endpoint.access, + "rules": endpoint.rules.iter().map(l7_rule_to_json).collect::>(), + "allowed_ips": endpoint.allowed_ips, + "ports": endpoint.ports, + "deny_rules": endpoint.deny_rules.iter().map(l7_deny_rule_to_json).collect::>(), + "allow_encoded_slash": endpoint.allow_encoded_slash, + "persisted_queries": endpoint.persisted_queries, + "graphql_persisted_queries": endpoint.graphql_persisted_queries.iter().map(|(key, value)| { + (key.clone(), graphql_operation_to_json(value)) + }).collect::>(), + "graphql_max_body_bytes": endpoint.graphql_max_body_bytes, + "path": endpoint.path, + "websocket_credential_rewrite": endpoint.websocket_credential_rewrite, + "request_body_credential_rewrite": endpoint.request_body_credential_rewrite, + "advisor_proposed": endpoint.advisor_proposed, + }) +} + +fn l7_rule_to_json(rule: &L7Rule) -> JsonValue { + json!({ "allow": rule.allow.as_ref().map(|allow| json!({ + "method": allow.method, + "path": allow.path, + "command": allow.command, + "query": query_map_to_json(&allow.query), + "operation_type": allow.operation_type, + "operation_name": allow.operation_name, + "fields": allow.fields, + })) }) +} + +fn l7_deny_rule_to_json(rule: &L7DenyRule) -> JsonValue { + json!({ + "method": rule.method, + "path": rule.path, + "command": rule.command, + "query": query_map_to_json(&rule.query), + "operation_type": rule.operation_type, + "operation_name": rule.operation_name, + "fields": rule.fields, + }) +} + +fn query_map_to_json(query: &std::collections::HashMap) -> JsonValue { + JsonValue::Object( + query + .iter() + .map(|(key, matcher)| { + let value = if matcher.any.is_empty() { + JsonValue::String(matcher.glob.clone()) + } else { + json!({ "any": matcher.any }) + }; + (key.clone(), value) + }) + .collect(), + ) +} + +fn graphql_operation_to_json(operation: &GraphqlOperation) -> JsonValue { + json!({ + "operation_type": operation.operation_type, + "operation_name": operation.operation_name, + "fields": operation.fields, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_interceptors::apply_proto_patches; + + fn interceptor() -> GovernanceInterceptor { + GovernanceInterceptor::new().expect("policy should parse") + } + + fn review( + phase: &str, + resource: &str, + operation: &str, + object: JsonValue, + ) -> InterceptorDecision { + review_with(&interceptor(), phase, resource, operation, object) + } + + fn review_with( + interceptor: &GovernanceInterceptor, + phase: &str, + resource: &str, + operation: &str, + object: JsonValue, + ) -> InterceptorDecision { + interceptor.review_decision(InterceptorReview { + api_version: API_VERSION.to_string(), + interceptor_name: "governance".to_string(), + binding_id: "test".to_string(), + phase: phase.to_string(), + resource: resource.to_string(), + operation: operation.to_string(), + object: Some(json_to_struct(&object).expect("object should convert")), + ..Default::default() + }) + } + + fn sandbox_object(policy: JsonValue, providers: JsonValue) -> JsonValue { + json!({ + "metadata": { "name": "demo", "labels": {} }, + "spec": { + "policy": policy, + "providers": providers, + }, + }) + } + + fn vended_policy() -> JsonValue { + interceptor().verified_policy.policy + } + + fn signature_label() -> String { + interceptor().verified_policy.policy_signature_label + } + + fn assert_signature_label(object: &JsonValue) { + assert_eq!( + object.pointer(&signature_label_pointer()), + Some(&JsonValue::String(signature_label())) + ); + } + + #[test] + fn manifest_declares_governance_bindings() { + let manifest = GovernanceInterceptor::manifest(); + assert_eq!(manifest.api_version, API_VERSION); + let unique_ids = manifest + .bindings + .iter() + .map(|binding| binding.id.as_str()) + .collect::>(); + assert_eq!(unique_ids.len(), manifest.bindings.len()); + assert!( + manifest + .bindings + .iter() + .any(|binding| { binding.id == "sandbox-create-defaults" && binding.modifies }) + ); + assert!(manifest.bindings.iter().any(|binding| { + binding.resource_matches("config") && binding.operation_matches("merge") + })); + } + + #[test] + fn sandbox_create_vends_missing_policy_and_governed_providers() { + let mut object = sandbox_object(JsonValue::Null, json!([])); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object.clone()); + assert!(decision.allowed); + assert_eq!(decision.patches.len(), 3); + + apply_proto_patches(&mut object, &decision.patches).expect("patches should apply"); + assert_eq!( + object.pointer("/spec/providers"), + Some(&governed_providers_json()) + ); + assert_eq!(object.pointer("/spec/policy"), Some(&vended_policy())); + assert_signature_label(&object); + } + + #[test] + fn sandbox_create_vends_policy_when_spec_omits_it() { + let mut object = json!({ + "metadata": { "name": "demo" }, + "spec": {}, + }); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object.clone()); + assert!(decision.allowed); + assert_eq!(decision.patches.len(), 3); + + apply_proto_patches(&mut object, &decision.patches).expect("patches should apply"); + assert_eq!( + object.pointer("/spec/providers"), + Some(&governed_providers_json()) + ); + assert_eq!(object.pointer("/spec/policy"), Some(&vended_policy())); + assert_signature_label(&object); + } + + #[test] + fn sandbox_create_allows_exact_governed_values_and_adds_missing_signature() { + let object = sandbox_object(vended_policy(), governed_providers_json()); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object); + assert!(decision.allowed); + assert_eq!(decision.patches.len(), 1); + } + + #[test] + fn sandbox_create_preserves_matching_signature() { + let mut object = sandbox_object(vended_policy(), governed_providers_json()); + object["metadata"]["labels"] = signature_label_json(&signature_label()); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object); + assert!(decision.allowed); + assert!(decision.patches.is_empty()); + } + + #[test] + fn sandbox_create_denies_conflicting_signature_label() { + let mut object = sandbox_object(JsonValue::Null, json!([])); + object["metadata"]["labels"] = json!({ + "governance.nvidia.com/signature": "caller-supplied", + }); + + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object); + assert!(!decision.allowed); + assert!(decision.reason.contains("reserved signature label")); + } + + #[test] + fn signature_label_is_signed_jwt_for_vendored_policy() { + let token = signature_label(); + let claims = verify_policy_signature(&token).expect("signature label should verify"); + + assert_eq!( + claims.policy_digest, + short_policy_digest(POLICY_YAML.as_bytes()) + ); + assert_eq!(token.split('.').count(), 3); + } + + #[test] + fn policy_signature_jwt_verifies_to_expected_claims() { + let claims = verify_policy_signature(&interceptor().verified_policy.signature) + .expect("policy signature should verify"); + + assert_eq!(claims.iss, SIGNATURE_ISSUER); + assert_eq!(claims.sub, SIGNATURE_SUBJECT); + assert_eq!(claims.version, SIGNATURE_VERSION); + assert_eq!(claims.valid_from, SIGNATURE_VALID_FROM); + assert_eq!(claims.expires_at, SIGNATURE_EXPIRES_AT); + assert_eq!(claims.issuer, SIGNATURE_ISSUER); + assert_eq!( + claims.policy_digest, + short_policy_digest(POLICY_YAML.as_bytes()) + ); + } + + #[test] + fn sandbox_create_denies_when_policy_signature_is_invalid() { + let mut interceptor = interceptor(); + interceptor.verified_policy.signature.push_str("tampered"); + + let decision = review_with( + &interceptor, + PHASE_MODIFY_OBJECT, + "sandbox", + "create", + sandbox_object(JsonValue::Null, json!([])), + ); + + assert!(!decision.allowed); + assert!( + decision + .reason + .contains("policy signature validation failed") + ); + } + + #[test] + fn sandbox_create_denies_when_cached_policy_is_stale() { + let mut interceptor = interceptor(); + interceptor.verified_policy.cache_valid_until_unix_secs = 0; + + let decision = review_with( + &interceptor, + PHASE_MODIFY_OBJECT, + "sandbox", + "create", + sandbox_object(JsonValue::Null, json!([])), + ); + + assert!(!decision.allowed); + assert!(decision.reason.contains("stale")); + } + + #[test] + fn sandbox_create_denies_when_policy_digest_is_revoked() { + let mut interceptor = interceptor(); + interceptor + .revoked_policy_digests + .insert(interceptor.verified_policy.policy_digest.clone()); + + let decision = review_with( + &interceptor, + PHASE_MODIFY_OBJECT, + "sandbox", + "create", + sandbox_object(JsonValue::Null, json!([])), + ); + + assert!(!decision.allowed); + assert!(decision.reason.contains("revoked")); + } + + #[test] + fn provider_create_fails_closed_when_cached_policy_is_stale() { + let mut interceptor = interceptor(); + interceptor.verified_policy.cache_valid_until_unix_secs = 0; + + let decision = review_with( + &interceptor, + PHASE_VALIDATE_OBJECT, + "provider", + "create", + json!({ "metadata": { "name": "github" }, "type": "github" }), + ); + + assert!(!decision.allowed); + assert!(decision.reason.contains("stale")); + } + + #[test] + fn sandbox_create_denies_extra_provider() { + let object = sandbox_object(JsonValue::Null, json!(["github", "gitlab", "slack"])); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object); + assert!(!decision.allowed); + assert!(decision.reason.contains("providers")); + } + + #[test] + fn sandbox_create_denies_non_governed_policy() { + let object = sandbox_object(json!({ "version": 1, "network_policies": {} }), json!([])); + let decision = review(PHASE_MODIFY_OBJECT, "sandbox", "create", object); + assert!(!decision.allowed); + assert!(decision.reason.contains("policy")); + } + + #[test] + fn sandbox_validate_requires_exact_governed_values() { + let mut object = sandbox_object(vended_policy(), governed_providers_json()); + object["metadata"]["labels"] = signature_label_json(&signature_label()); + let decision = review(PHASE_VALIDATE_OBJECT, "sandbox", "create", object); + assert!(decision.allowed); + + let object = sandbox_object(JsonValue::Null, governed_providers_json()); + let decision = review(PHASE_VALIDATE_OBJECT, "sandbox", "create", object); + assert!(!decision.allowed); + } + + #[test] + fn provider_attach_and_detach_are_denied() { + let attach = review( + PHASE_VALIDATE_OBJECT, + "sandbox", + "attach_provider", + json!({}), + ); + assert!(!attach.allowed); + let detach = review( + PHASE_VALIDATE_OBJECT, + "sandbox", + "detach_provider", + json!({}), + ); + assert!(!detach.allowed); + } + + #[test] + fn policy_config_updates_are_denied_but_settings_are_allowed() { + let policy_update = review( + PHASE_VALIDATE_OBJECT, + "config", + "update", + json!({ "policy": vended_policy(), "merge_operations": [], "setting_key": "" }), + ); + assert!(!policy_update.allowed); + + let merge = review( + PHASE_VALIDATE_OBJECT, + "config", + "merge", + json!({ "policy": null, "merge_operations": [{ "op": "noop" }], "setting_key": "" }), + ); + assert!(!merge.allowed); + + let setting = review( + PHASE_VALIDATE_OBJECT, + "config", + "update", + json!({ "policy": null, "merge_operations": [], "setting_key": "providers_v2_enabled" }), + ); + assert!(setting.allowed); + } + + #[test] + fn only_governed_provider_records_are_allowed() { + let github = review( + PHASE_VALIDATE_OBJECT, + "provider", + "create", + json!({ "metadata": { "name": "github" }, "type": "github" }), + ); + assert!(github.allowed); + + let wrong_type = review( + PHASE_VALIDATE_OBJECT, + "provider", + "create", + json!({ "metadata": { "name": "github" }, "type": "gitlab" }), + ); + assert!(!wrong_type.allowed); + + let extra = review( + PHASE_VALIDATE_OBJECT, + "provider", + "create", + json!({ "metadata": { "name": "slack" }, "type": "generic" }), + ); + assert!(!extra.allowed); + } + + #[test] + fn provider_update_delete_and_profile_changes_are_denied() { + assert!( + !review( + PHASE_VALIDATE_OBJECT, + "provider", + "update", + json!({ "metadata": { "name": "github" }, "type": "github" }) + ) + .allowed + ); + assert!(!review(PHASE_VALIDATE_OBJECT, "provider", "delete", json!({})).allowed); + assert!( + !review( + PHASE_VALIDATE_OBJECT, + "provider_profile", + "import", + json!({}) + ) + .allowed + ); + assert!( + !review( + PHASE_VALIDATE_OBJECT, + "provider_profile", + "delete", + json!({}) + ) + .allowed + ); + } + + trait BindingTestExt { + fn resource_matches(&self, resource: &str) -> bool; + fn operation_matches(&self, operation: &str) -> bool; + } + + impl BindingTestExt for InterceptorBinding { + fn resource_matches(&self, resource: &str) -> bool { + self.resources.iter().any(|value| value == resource) + } + + fn operation_matches(&self, operation: &str) -> bool { + self.operations.iter().any(|value| value == operation) + } + } +} diff --git a/examples/policy-governance-interceptor/src/main.rs b/examples/policy-governance-interceptor/src/main.rs new file mode 100644 index 000000000..453415b40 --- /dev/null +++ b/examples/policy-governance-interceptor/src/main.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; + +use openshell_core::proto::interceptor::v1::gateway_interceptor_server::GatewayInterceptorServer; +use policy_governance_interceptor::GovernanceInterceptor; +use tonic::transport::Server; + +const DEFAULT_ADDR: &str = "127.0.0.1:18098"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let addr: SocketAddr = std::env::args() + .nth(1) + .unwrap_or_else(|| DEFAULT_ADDR.to_string()) + .parse()?; + tracing::info!(%addr, "policy governance interceptor listening"); + + let interceptor = GovernanceInterceptor::new() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + + Server::builder() + .add_service(GatewayInterceptorServer::new(interceptor)) + .serve(addr) + .await?; + + Ok(()) +} diff --git a/proto/interceptor.proto b/proto/interceptor.proto new file mode 100644 index 000000000..31dcea722 --- /dev/null +++ b/proto/interceptor.proto @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.interceptor.v1; + +import "google/protobuf/struct.proto"; + +// GatewayInterceptor is implemented by external operation interceptor services. +service GatewayInterceptor { + // Describe returns the service-declared operation bindings for this + // configured interceptor instance. + rpc Describe(InterceptorDescribeRequest) returns (InterceptorManifest); + + // Review evaluates one selected operation binding at a specific gateway + // operation phase. + rpc Review(InterceptorReview) returns (InterceptorDecision); +} + +// Describe request sent once at gateway startup for each configured service. +message InterceptorDescribeRequest { + // Contract version requested by the gateway. + string api_version = 1; + // Operator-configured interceptor service name. + string interceptor_name = 2; + // Operator-supplied service-specific config from gateway TOML. + google.protobuf.Struct config = 3; +} + +// Service manifest returned by Describe. +message InterceptorManifest { + // Contract version implemented by the service. + string api_version = 1; + // Service-declared operation bindings. + repeated InterceptorBinding bindings = 2; +} + +// One service-declared operation binding. +message InterceptorBinding { + // Service-owned binding identifier. + string id = 1; + // Operation phases this binding participates in. + repeated string phases = 2; + // Logical resources this binding selects. + repeated string resources = 3; + // Logical operations this binding selects. + repeated string operations = 4; + // Service-declared order within the interceptor service. + int32 order = 5; + // True when this binding intends to return JSON patches in modification phases. + bool modifies = 6; + // Default failure policy: fail_closed, fail_open, or ignore. + string default_failure_policy = 7; + // Additional selector constraints. + InterceptorSelector selector = 8; +} + +// Selector fields for operation bindings. Empty fields match all values. +message InterceptorSelector { + repeated string principal_kinds = 1; + repeated string principal_groups = 2; + map labels = 3; + repeated string compute_drivers = 4; +} + +// Operation review request sent for a selected binding. +message InterceptorReview { + string api_version = 1; + string interceptor_name = 2; + string binding_id = 3; + string phase = 4; + string resource = 5; + string operation = 6; + + InterceptorPrincipal principal = 7; + InterceptorRequestContext context = 8; + + google.protobuf.Struct object = 9; + google.protobuf.Struct old_object = 10; + google.protobuf.Struct request = 11; +} + +// Authenticated caller summary. +message InterceptorPrincipal { + // user, service, sandbox, or anonymous. + string kind = 1; + // Stable subject identifier. + string subject = 2; + // Authenticated group or role names. + repeated string groups = 3; +} + +// Gateway request context visible to interceptors. +message InterceptorRequestContext { + string request_id = 1; + string gateway_replica_id = 2; + string compute_driver = 3; + bool dry_run = 4; + map labels = 5; +} + +// Operation interceptor decision. +message InterceptorDecision { + bool allowed = 1; + string reason = 2; + string status_code = 3; + repeated JsonPatch patches = 4; + repeated string warnings = 5; + map audit_annotations = 6; +} + +// RFC 6902-style JSON patch operation. +message JsonPatch { + string op = 1; + string path = 2; + string from = 3; + google.protobuf.Value value = 4; +}