diff --git a/AGENTS.md b/AGENTS.md index 7e494a7b5d..45dfaeae73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,11 +39,13 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers | | `crates/openshell-otel/` | OpenTelemetry support | Shared OTLP trace provider, resource, and tracing-layer construction | | `crates/openshell-core/` | Shared core | Common types, configuration, error handling | +| `crates/openshell-extension-core/` | Extension core | Shared extension identity, JWT claims, bearer-token rotation, and TLS transport primitives | | `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` | | `crates/openshell-providers/` | Provider management | Credential provider backends | | `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring | | `crates/openshell-driver-kubernetes-secrets/` | Kubernetes Secrets credential driver | In-process `CredentialDriver` backend for OpenShell-managed K8s Secret storage | | `crates/openshell-driver-vault/` | Vault credential driver | In-process `CredentialDriver` backend for Vault-compatible KV storage | +| `crates/openshell-driver-db-credstore/` | Database credential driver | In-process `CredentialDriver` backend for gateway database credential storage | | `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods | | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64b9d85b04..2814187857 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,6 @@ Skills live in `.agents/skills/`. Your agent's harness can discover and load the | Contributing | `create-github-issue` | Create well-structured GitHub issues | | Contributing | `create-github-pr` | Create pull requests with proper conventions | | Reviewing | `review-github-pr` | Summarize PR diffs and key design decisions | -| Reviewing | `review-security-changes` | Review code changes for security vulnerabilities and boundary regressions | | Reviewing | `review-security-issue` | Assess security issues for severity and remediation | | Reviewing | `fix-security-issue` | Implement an approved security remediation plan | | Reviewing | `watch-github-actions` | Monitor CI pipeline status and logs | diff --git a/Cargo.lock b/Cargo.lock index 88c7dc0b7c..663ae6ce72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3732,6 +3732,7 @@ dependencies = [ "ipnet", "miette", "nix 0.29.0", + "openshell-extension-core", "prost", "prost-types", "protobuf-src", @@ -3921,13 +3922,30 @@ dependencies = [ ] [[package]] -name = "openshell-gateway-interceptors" +name = "openshell-extension-core" version = "0.0.0" dependencies = [ + "http 1.4.0", "hyper-util", + "rcgen", + "rustls 0.23.38", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tower 0.5.3", +] + +[[package]] +name = "openshell-gateway-interceptors" +version = "0.0.0" +dependencies = [ "json-patch", "metrics", "openshell-core", + "openshell-extension-core", "prost", "prost-reflect", "prost-types", @@ -3936,7 +3954,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tonic", - "tower 0.5.3", "tracing", "tracing-subscriber", ] @@ -4036,6 +4053,7 @@ dependencies = [ "miette", "nix 0.29.0", "openshell-core", + "openshell-extension-core", "openshell-ocsf", "openshell-policy", "openshell-supervisor-middleware", @@ -4124,6 +4142,7 @@ dependencies = [ "openshell-driver-kubernetes-secrets", "openshell-driver-podman", "openshell-driver-vault", + "openshell-extension-core", "openshell-gateway-interceptors", "openshell-ocsf", "openshell-otel", @@ -4187,6 +4206,7 @@ version = "0.0.0" dependencies = [ "miette", "openshell-core", + "openshell-extension-core", "openshell-supervisor-middleware-builtins", "prost", "prost-types", diff --git a/architecture/gateway.md b/architecture/gateway.md index f087dc6378..7646fae991 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -74,6 +74,13 @@ until deliberately added to this allowlist. Interception remains centralized: allowlisting a unary RPC does not require method-specific gateway instrumentation. +Remote extension clients share `openshell-extension-core` transport and bearer +primitives. When gateway JWT signing is configured, the gateway mints +short-lived, exact-audience EdDSA credentials for middleware and interceptors, +rotates their in-memory slots without rebuilding clients, and publishes the +public verification key at `/.well-known/jwks.json`. HTTPS extensions can pin +an operator-provided CA while retaining endpoint-hostname verification. + Each configured interceptor selects a binding policy. `dynamic` accepts valid manifest declarations and preserves the compatibility behavior. `allowlist` enables only operator-configured RPCs and phases, while `exact` requires the diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a39f699a57..45a4314ebc 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -101,6 +101,13 @@ generation and preserves the last-known-good generation if preparation fails. Policy-only updates reuse the connected registry, so an external middleware outage cannot block unrelated policy changes. +For authenticated operator middleware, the supervisor requests credentials by +registration name through `RefreshSandboxToken`. The gateway resolves names +against the effective policy and mints exact-audience credentials. The +supervisor keeps them in refreshable in-memory slots outside stable middleware +configuration, so rotation neither changes `config_revision` nor reconnects +the registry. Public custom-CA PEM travels with the stable registration. + Middleware cannot observe injected credentials or mutate supervisor-owned credential, routing, or framing headers. Body transformations are re-evaluated against body-aware L7 policy before later stages or the upstream can observe diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index e138e1eee1..ed71d97f05 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +openshell-extension-core = { path = "../openshell-extension-core" } glob = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 2107f11361..aff09906a2 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -4,6 +4,7 @@ //! Configuration management for `OpenShell` components. use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::BTreeMap; use std::fmt; #[cfg(unix)] @@ -630,6 +631,14 @@ pub struct GatewayInterceptorConfig { /// Interceptor gRPC endpoint. Supports `http://`, `https://`, and /// `unix://` endpoints. pub grpc_endpoint: String, + /// Optional PEM trust-root bundle for an HTTPS endpoint. The gateway + /// loads this file during interceptor initialization. + #[serde(default)] + pub tls_ca_cert_path: Option, + /// Exact JWT audience for this service. When omitted, a kind-scoped value + /// is derived from the configured registration name. + #[serde(default)] + pub audience: Option, /// Deterministic service ordering. Lower values run first. #[serde(default)] pub order: i32, @@ -655,6 +664,19 @@ pub struct GatewayInterceptorConfig { pub bindings: Vec, } +impl GatewayInterceptorConfig { + /// Resolve the configured JWT audience to its deterministic default. + pub fn resolved_audience(&self) -> Cow<'_, str> { + self.audience + .as_deref() + .filter(|audience| !audience.is_empty()) + .map_or_else( + || Cow::Owned(format!("urn:openshell:extension:interceptor:{}", self.name)), + Cow::Borrowed, + ) + } +} + /// Operator policy for authorizing interceptor manifest bindings. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -1205,6 +1227,19 @@ mod tests { defaulted.binding_policy, GatewayInterceptorBindingPolicy::Dynamic ); + assert_eq!( + defaulted.resolved_audience(), + "urn:openshell:extension:interceptor:governance" + ); + let explicitly_empty = GatewayInterceptorConfig { + name: "governance".to_string(), + audience: Some(String::new()), + ..GatewayInterceptorConfig::default() + }; + assert_eq!( + explicitly_empty.resolved_audience(), + "urn:openshell:extension:interceptor:governance" + ); assert_eq!(allowlist, GatewayInterceptorBindingPolicy::Allowlist); assert_eq!(exact, GatewayInterceptorBindingPolicy::Exact); } diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 579ee4a5b3..9b8f00d20c 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -32,6 +32,7 @@ use crate::proto::{ }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_extension_core::BearerTokenSlot; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; @@ -69,6 +70,10 @@ static TOKEN_INIT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// One-shot guard so the renewal loop spawns at most once per process. static REFRESH_SPAWNED: OnceLock<()> = OnceLock::new(); +/// Process-wide extension credential slots keyed by operator registration +/// name. Middleware clients retain clones so refresh never rebuilds channels. +static EXTENSION_TOKEN_SLOTS: OnceLock>> = OnceLock::new(); + #[derive(Clone, Debug)] enum RefreshMode { GatewayJwt(TokenSource), @@ -338,7 +343,9 @@ async fn refresh_token_loop( let sleep = compute_refresh_delay(&slot); tokio::time::sleep(sleep).await; match client - .refresh_sandbox_token(RefreshSandboxTokenRequest {}) + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }) .await { Ok(resp) => { @@ -403,6 +410,154 @@ async fn refresh_token_loop( } } +fn extension_token_slots() -> &'static RwLock> { + EXTENSION_TOKEN_SLOTS.get_or_init(|| RwLock::new(HashMap::new())) +} + +fn compute_extension_credential_refresh_delay( + expiries_ms: impl Iterator, + fallback: Duration, + now_ms: i64, +) -> Duration { + let Some(earliest_expiry_ms) = expiries_ms.min() else { + return fallback; + }; + let remaining_ms = earliest_expiry_ms.saturating_sub(now_ms); + let refresh_ms = if remaining_ms <= 0 { + 1_000 + } else { + u64::try_from(remaining_ms) + .unwrap_or(u64::MAX) + .saturating_mul(4) + .checked_div(5) + .unwrap_or(100) + .max(100) + }; + fallback.min(Duration::from_millis(refresh_ms)) +} + +/// Bound a caller's normal wait by 80% of the earliest installed extension +/// credential lifetime. Expired slots produce a short retry delay. +pub fn extension_credential_refresh_delay(fallback: Duration) -> Duration { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }); + let slots = extension_token_slots() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + compute_extension_credential_refresh_delay( + slots.values().filter_map(BearerTokenSlot::expires_at_ms), + fallback, + now_ms, + ) +} + +async fn refresh_extension_credentials_with_client( + client: &mut OpenShellClient, + services: &[crate::proto::SupervisorMiddlewareService], +) -> Result> { + let names = services + .iter() + .map(|service| service.name.clone()) + .collect::>(); + if names.is_empty() { + return Ok(HashMap::new()); + } + + let response = client + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: names.clone(), + }) + .await + .into_diagnostic() + .wrap_err("failed to refresh extension service credentials")? + .into_inner(); + + // The same refresh response renews the gateway credential. Install it + // before returning so all process-wide gateway clients stay current. + install_token_slot(&response.token)?; + + let expected = names + .iter() + .map(String::as_str) + .collect::>(); + let mut validated = HashMap::with_capacity(response.extension_credentials.len()); + for credential in response.extension_credentials { + if !expected.contains(credential.service_name.as_str()) + || validated.contains_key(&credential.service_name) + { + return Err(miette::miette!( + "gateway returned an unexpected or duplicate extension credential" + )); + } + let slot = BearerTokenSlot::new(&credential.token, credential.expires_at_ms) + .into_diagnostic() + .wrap_err("gateway returned an invalid extension credential")?; + validated.insert( + credential.service_name, + (credential.token, credential.expires_at_ms, slot), + ); + } + if validated.len() != expected.len() { + return Err(miette::miette!( + "gateway omitted one or more requested extension credentials" + )); + } + + let mut slots = extension_token_slots() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut selected = HashMap::with_capacity(validated.len()); + for (name, (token, expires_at_ms, new_slot)) in validated { + let slot = if let Some(existing) = slots.get(&name) { + existing + .update(&token, expires_at_ms) + .into_diagnostic() + .wrap_err("failed to update extension credential")?; + existing.clone() + } else { + slots.insert(name.clone(), new_slot.clone()); + new_slot + }; + selected.insert(name, slot); + } + Ok(selected) +} + +/// Clear credentials that are no longer part of the successfully installed +/// middleware registry. +/// +/// Call this only after the registry swap succeeds so a failed candidate +/// cannot invalidate the last-known-good clients. +pub fn retain_extension_credentials(services: &[crate::proto::SupervisorMiddlewareService]) { + let retained = services + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + let mut slots = extension_token_slots() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slots.retain(|name, slot| { + let keep = retained.contains(name.as_str()); + if !keep { + slot.clear(); + } + keep + }); +} + +/// Acquire or rotate credentials for the delivered middleware registrations. +/// Returned slots remain shared with subsequent refreshes in this process. +pub async fn refresh_extension_credentials( + endpoint: &str, + services: &[crate::proto::SupervisorMiddlewareService], +) -> Result> { + let mut client = connect(endpoint).await?; + refresh_extension_credentials_with_client(&mut client, services).await +} + /// Compute the next refresh delay: 80 % of the time remaining until the /// current token's `exp`, plus up to 10 % jitter, with a small lower bound /// for already-expired tokens and capped at 12 h. If the token can't be parsed @@ -450,6 +605,55 @@ fn parse_jwt_exp_ms(jwt: &str) -> Option { #[cfg(test)] mod auth_tests { use super::*; + use tonic::service::Interceptor; + + #[test] + fn clearing_extension_slots_invalidates_detached_credentials() { + retain_extension_credentials(&[]); + let slot = BearerTokenSlot::new("detached-secret", i64::MAX).unwrap(); + extension_token_slots() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert("detached-service".to_string(), slot.clone()); + + retain_extension_credentials(&[]); + + assert!( + extension_token_slots() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + ); + assert_eq!( + slot.interceptor() + .call(tonic::Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[test] + fn extension_refresh_delay_tracks_earliest_expiry() { + let delay = compute_extension_credential_refresh_delay( + [20_000, 10_000].into_iter(), + Duration::from_secs(60), + 0, + ); + assert_eq!(delay, Duration::from_secs(8)); + assert_eq!( + compute_extension_credential_refresh_delay( + std::iter::empty(), + Duration::from_secs(60), + 0, + ), + Duration::from_secs(60) + ); + assert_eq!( + compute_extension_credential_refresh_delay([1].into_iter(), Duration::from_secs(60), 2,), + Duration::from_secs(1) + ); + } #[test] fn parse_jwt_exp_reads_unsigned_payload() { @@ -873,6 +1077,36 @@ impl CachedOpenShellClient { Ok(result) } + /// Acquire or rotate extension credentials over this cached gateway + /// connection and return the shared slots for the requested services. + pub async fn refresh_extension_credentials( + &self, + services: &[crate::proto::SupervisorMiddlewareService], + ) -> Result> { + let mut client = self.client.clone(); + refresh_extension_credentials_with_client(&mut client, services).await + } + + /// Rotate every credential currently retained by the installed registry. + /// This remains available when configuration polling fails independently. + pub async fn refresh_installed_extension_credentials(&self) -> Result<()> { + let services = extension_token_slots() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .map(|name| crate::proto::SupervisorMiddlewareService { + name: name.clone(), + ..Default::default() + }) + .collect::>(); + if services.is_empty() { + return Ok(()); + } + self.refresh_extension_credentials(&services) + .await + .map(drop) + } + /// Returns the workspace learned from the server, or empty if not yet polled. pub fn workspace(&self) -> String { self.workspace.get().cloned().unwrap_or_default() diff --git a/crates/openshell-extension-core/BUILD.bazel b/crates/openshell-extension-core/BUILD.bazel new file mode 100644 index 0000000000..bd8cf31ab9 --- /dev/null +++ b/crates/openshell-extension-core/BUILD.bazel @@ -0,0 +1,27 @@ +load("@crates//:defs.bzl", "aliases", "all_crate_deps") +load("@rules_rs//rs:rust_library.bzl", "rust_library") +load("@rules_rs//rs:rust_test.bzl", "rust_test") +load("@rules_rust//rust:defs.bzl", "rustfmt_test") + +rust_library( + name = "openshell-extension-core", + srcs = glob(["src/**/*.rs"]), + aliases = aliases(), + visibility = ["//visibility:public"], + deps = all_crate_deps(normal = True), +) + +rust_test( + name = "openshell-extension-core_test", + crate = ":openshell-extension-core", + deps = all_crate_deps(normal_dev = True), +) + +rustfmt_test( + name = "rustfmt_test", + targets = [ + ":openshell-extension-core", + ":openshell-extension-core_test", + ], + visibility = ["//crates:__pkg__"], +) diff --git a/crates/openshell-extension-core/Cargo.toml b/crates/openshell-extension-core/Cargo.toml new file mode 100644 index 0000000000..babbeca295 --- /dev/null +++ b/crates/openshell-extension-core/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-extension-core" +description = "Shared extension identity, authentication, and transport primitives for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +hyper-util = { workspace = true, features = ["tokio"] } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tower = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true } +serde_json = { workspace = true } +tokio-stream = { workspace = true, features = ["net"] } +tonic = { workspace = true, features = ["server", "tls-native-roots"] } + +[lints] +workspace = true diff --git a/crates/openshell-extension-core/README.md b/crates/openshell-extension-core/README.md new file mode 100644 index 0000000000..e2a44d86aa --- /dev/null +++ b/crates/openshell-extension-core/README.md @@ -0,0 +1,12 @@ +# OpenShell extension core + +`openshell-extension-core` contains protocol-neutral primitives shared by two or +more OpenShell extension mechanisms. It currently owns extension identity and +audience values, refreshable bearer credentials, and outbound gRPC transport +construction for HTTP, HTTPS, and Unix sockets. + +Middleware- or interceptor-specific protobuf clients, policy selection, +orchestration, and lifecycle management stay in their owning crates. Gateway +signing authority also stays in `openshell-server`. This ownership rule keeps +this crate from becoming a general-purpose dumping ground as extension support +grows. diff --git a/crates/openshell-extension-core/src/auth.rs b/crates/openshell-extension-core/src/auth.rs new file mode 100644 index 0000000000..cc559754a3 --- /dev/null +++ b/crates/openshell-extension-core/src/auth.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tonic::metadata::AsciiMetadataValue; +use tonic::{Request, Status}; + +#[derive(Clone)] +pub struct BearerTokenSlot { + inner: Arc>>, +} + +#[derive(Clone)] +struct Token { + authorization: AsciiMetadataValue, + expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TokenSlotError { + #[error("extension bearer token is not valid for an HTTP authorization header")] + InvalidToken, + #[error("extension bearer token expiry must be a positive Unix timestamp in milliseconds")] + InvalidExpiry, +} + +impl BearerTokenSlot { + /// Create an empty slot. Requests fail closed until [`Self::update`] is called. + pub fn empty() -> Self { + Self { + inner: Arc::new(RwLock::new(None)), + } + } + + pub fn new(token: &str, expires_at_ms: i64) -> Result { + let slot = Self::empty(); + slot.update(token, expires_at_ms)?; + Ok(slot) + } + + /// Replace the credential without rebuilding channels or generated clients. + pub fn update(&self, token: &str, expires_at_ms: i64) -> Result<(), TokenSlotError> { + if expires_at_ms <= 0 { + return Err(TokenSlotError::InvalidExpiry); + } + if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(TokenSlotError::InvalidToken); + } + let authorization = AsciiMetadataValue::try_from(format!("Bearer {token}")) + .map_err(|_| TokenSlotError::InvalidToken)?; + *self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Token { + authorization, + expires_at_ms, + }); + Ok(()) + } + + /// Remove the credential immediately. Subsequent requests fail closed. + pub fn clear(&self) { + *self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + + pub fn expires_at_ms(&self) -> Option { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|token| token.expires_at_ms) + } + + pub fn interceptor(&self) -> BearerTokenInterceptor { + BearerTokenInterceptor { + slot: Some(self.clone()), + } + } + + fn authorization_at(&self, now_ms: i64) -> Result { + let guard = self + .inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let token = guard + .as_ref() + .ok_or_else(|| Status::unauthenticated("extension bearer token is unavailable"))?; + if token.expires_at_ms <= now_ms { + return Err(Status::unauthenticated( + "extension bearer token has expired", + )); + } + Ok(token.authorization.clone()) + } +} + +impl Default for BearerTokenSlot { + fn default() -> Self { + Self::empty() + } +} + +impl fmt::Debug for BearerTokenSlot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerTokenSlot") + .field("expires_at_ms", &self.expires_at_ms()) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub struct BearerTokenInterceptor { + slot: Option, +} + +impl BearerTokenInterceptor { + /// Create an explicit no-op interceptor for legacy registrations that have + /// not opted into audience authentication. + pub const fn disabled() -> Self { + Self { slot: None } + } +} + +impl fmt::Debug for BearerTokenInterceptor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerTokenInterceptor") + .field("enabled", &self.slot.is_some()) + .field("slot", &self.slot) + .finish() + } +} + +impl tonic::service::Interceptor for BearerTokenInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + let Some(slot) = &self.slot else { + return Ok(request); + }; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }); + let authorization = slot.authorization_at(now_ms)?; + request + .metadata_mut() + .insert("authorization", authorization); + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use tonic::service::Interceptor; + + use super::*; + + #[test] + fn slot_rotates_all_interceptor_clones_in_place() { + let slot = BearerTokenSlot::new("first-secret", i64::MAX).unwrap(); + let mut first = slot.interceptor(); + let mut second = first.clone(); + + assert_eq!( + first + .call(Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer first-secret" + ); + slot.update("second-secret", i64::MAX).unwrap(); + assert_eq!( + second + .call(Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer second-secret" + ); + } + + #[test] + fn empty_expired_and_cleared_slots_fail_closed() { + let slot = BearerTokenSlot::empty(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + + slot.update("expired", 1).unwrap(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + + slot.update("current", i64::MAX).unwrap(); + slot.clear(); + assert_eq!( + slot.interceptor() + .call(Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[test] + fn debug_and_errors_do_not_expose_token_material() { + let secret = "super-secret-extension-token"; + let slot = BearerTokenSlot::new(secret, i64::MAX).unwrap(); + assert!(!format!("{slot:?}").contains(secret)); + assert!(!format!("{:?}", slot.interceptor()).contains(secret)); + + let error = BearerTokenSlot::new("contains\nnewline", i64::MAX).unwrap_err(); + assert!(!error.to_string().contains("contains")); + assert_eq!( + BearerTokenSlot::new("", i64::MAX).unwrap_err(), + TokenSlotError::InvalidToken + ); + } + + #[test] + fn disabled_interceptor_leaves_authorization_untouched() { + let mut interceptor = BearerTokenInterceptor::disabled(); + let mut request = Request::new(()); + request + .metadata_mut() + .insert("authorization", "Bearer caller-value".parse().unwrap()); + let request = interceptor.call(request).unwrap(); + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer caller-value" + ); + assert!(format!("{interceptor:?}").contains("enabled: false")); + } +} diff --git a/crates/openshell-extension-core/src/identity.rs b/crates/openshell-extension-core/src/identity.rs new file mode 100644 index 0000000000..a6b1bb86fd --- /dev/null +++ b/crates/openshell-extension-core/src/identity.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Extension mechanism that owns a service registration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionKind { + Middleware, + Interceptor, +} + +impl ExtensionKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Middleware => "middleware", + Self::Interceptor => "interceptor", + } + } +} + +impl fmt::Display for ExtensionKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// A gateway-owned registration name for a middleware or interceptor service. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExtensionIdentity(String); + +/// The exact JWT audience expected by an extension service. +/// +/// Audiences are intentionally opaque. Callers must resolve them from trusted +/// gateway configuration rather than constructing them from untrusted input. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ExtensionAudience(String); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum IdentityError { + #[error("extension {kind} must not be empty")] + Empty { kind: &'static str }, + #[error("extension {kind} must not have leading or trailing whitespace")] + SurroundingWhitespace { kind: &'static str }, + #[error("extension {kind} must not contain control characters")] + ControlCharacter { kind: &'static str }, +} + +macro_rules! opaque_value { + ($ty:ident, $kind:literal) => { + impl $ty { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate(&value, $kind)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + } + + impl fmt::Debug for $ty { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple(stringify!($ty)) + .field(&self.0) + .finish() + } + } + + impl fmt::Display for $ty { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + + impl AsRef for $ty { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl FromStr for $ty { + type Err = IdentityError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } + } + }; +} + +opaque_value!(ExtensionIdentity, "identity"); +opaque_value!(ExtensionAudience, "audience"); + +impl ExtensionAudience { + /// Build the deterministic fallback audience for a validated registration. + /// + /// Explicit operator-configured audiences remain opaque and take precedence. + /// This helper gives both extension mechanisms the same fallback namespace. + pub fn for_registration( + kind: ExtensionKind, + registration_name: &str, + ) -> Result { + let identity = ExtensionIdentity::new(registration_name)?; + Ok(Self(format!("urn:openshell:extension:{kind}:{identity}"))) + } +} + +fn validate(value: &str, kind: &'static str) -> Result<(), IdentityError> { + if value.is_empty() { + return Err(IdentityError::Empty { kind }); + } + if value.trim() != value { + return Err(IdentityError::SurroundingWhitespace { kind }); + } + if value.chars().any(char::is_control) { + return Err(IdentityError::ControlCharacter { kind }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_and_audience_are_opaque_exact_values() { + let identity = ExtensionIdentity::new("content-filter").unwrap(); + let audience = ExtensionAudience::new("https://filters.example/openshell").unwrap(); + assert_eq!(identity.as_str(), "content-filter"); + assert_eq!(audience.as_str(), "https://filters.example/openshell"); + } + + #[test] + fn values_reject_ambiguous_whitespace_and_controls() { + assert_eq!( + ExtensionIdentity::new(" content-filter").unwrap_err(), + IdentityError::SurroundingWhitespace { kind: "identity" } + ); + assert_eq!( + ExtensionAudience::new("audience\n").unwrap_err(), + IdentityError::SurroundingWhitespace { kind: "audience" } + ); + assert_eq!( + ExtensionAudience::new("").unwrap_err(), + IdentityError::Empty { kind: "audience" } + ); + } + + #[test] + fn fallback_audience_is_kind_scoped_and_validates_registration() { + assert_eq!( + ExtensionAudience::for_registration(ExtensionKind::Middleware, "content-filter") + .unwrap() + .as_str(), + "urn:openshell:extension:middleware:content-filter" + ); + assert_eq!( + ExtensionAudience::for_registration(ExtensionKind::Interceptor, "content-filter") + .unwrap() + .as_str(), + "urn:openshell:extension:interceptor:content-filter" + ); + assert!(matches!( + ExtensionAudience::for_registration(ExtensionKind::Middleware, " bad-name"), + Err(IdentityError::SurroundingWhitespace { kind: "identity" }) + )); + } +} diff --git a/crates/openshell-extension-core/src/jwt.rs b/crates/openshell-extension-core/src/jwt.rs new file mode 100644 index 0000000000..39801b5502 --- /dev/null +++ b/crates/openshell-extension-core/src/jwt.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Maximum accepted lifetime for an extension bearer token. +/// +/// Extension credentials cross the gateway trust boundary and must remain +/// short-lived even when legacy sandbox bootstrap credentials do not expire. +pub const MAX_EXTENSION_TOKEN_TTL: Duration = Duration::from_secs(3_600); + +/// `OpenShell` component calling an extension service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionCallerKind { + Gateway, + Supervisor, +} + +/// JWT claim set accepted by external extension services. +/// +/// This intentionally differs from sandbox bootstrap claims. Sharing a signing +/// key does not make a sandbox-to-gateway credential valid at an extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtensionJwtClaims { + pub iss: String, + pub aud: String, + pub sub: String, + pub iat: i64, + pub exp: i64, + pub jti: String, + pub caller_kind: ExtensionCallerKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caller_kind_uses_stable_snake_case_wire_values() { + assert_eq!( + serde_json::to_string(&ExtensionCallerKind::Gateway).unwrap(), + "\"gateway\"" + ); + assert_eq!( + serde_json::to_string(&ExtensionCallerKind::Supervisor).unwrap(), + "\"supervisor\"" + ); + } + + #[test] + fn gateway_claims_omit_sandbox_id() { + let claims = ExtensionJwtClaims { + iss: "openshell-gateway:test".to_string(), + aud: "urn:openshell:extension:interceptor:test".to_string(), + sub: "openshell-gateway:test".to_string(), + iat: 1, + exp: 2, + jti: "unique".to_string(), + caller_kind: ExtensionCallerKind::Gateway, + sandbox_id: None, + }; + let json = serde_json::to_value(claims).unwrap(); + assert!(json.get("sandbox_id").is_none()); + assert_eq!(json["caller_kind"], "gateway"); + } +} diff --git a/crates/openshell-extension-core/src/lib.rs b/crates/openshell-extension-core/src/lib.rs new file mode 100644 index 0000000000..4517dc57cc --- /dev/null +++ b/crates/openshell-extension-core/src/lib.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Protocol-neutral primitives shared by `OpenShell` extension mechanisms. +//! +//! Subsystem-specific protobuf clients and orchestration do not belong here. + +mod auth; +mod identity; +mod jwt; +mod transport; + +pub use auth::{BearerTokenInterceptor, BearerTokenSlot, TokenSlotError}; +pub use identity::{ExtensionAudience, ExtensionIdentity, ExtensionKind, IdentityError}; +pub use jwt::{ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL}; +pub use transport::{ExtensionChannelConfig, TransportError, connect_channel}; diff --git a/crates/openshell-extension-core/src/transport.rs b/crates/openshell-extension-core/src/transport.rs new file mode 100644 index 0000000000..0e9e72988d --- /dev/null +++ b/crates/openshell-extension-core/src/transport.rs @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(unix)] +use hyper_util::rt::TokioIo; +#[cfg(unix)] +use tokio::net::UnixStream; +#[cfg(unix)] +use tonic::transport::Uri; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint}; +#[cfg(unix)] +use tower::service_fn; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); +const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Configuration for an outbound extension gRPC channel. +#[derive(Clone, PartialEq, Eq)] +pub struct ExtensionChannelConfig { + endpoint: String, + custom_ca_pem: Option>, +} + +impl ExtensionChannelConfig { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + custom_ca_pem: None, + } + } + + /// Pin HTTPS verification to this CA bundle instead of platform roots. + /// Normal TLS hostname verification remains enabled. + #[must_use] + pub fn with_custom_ca_pem(mut self, custom_ca_pem: impl Into>) -> Self { + self.custom_ca_pem = Some(custom_ca_pem.into()); + self + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn has_custom_ca(&self) -> bool { + self.custom_ca_pem.is_some() + } +} + +impl std::fmt::Debug for ExtensionChannelConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionChannelConfig") + .field("endpoint", &self.endpoint) + .field("has_custom_ca", &self.has_custom_ca()) + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TransportError { + #[error("extension endpoint must not be empty")] + EmptyEndpoint, + #[error("extension endpoint must use http://, https://, or unix://")] + UnsupportedScheme, + #[error("custom CA certificates require an https:// extension endpoint")] + CustomCaRequiresHttps, + #[error("unix extension endpoint must contain an absolute socket path")] + InvalidUnixPath, + #[error("unix extension endpoints are not supported on this platform")] + UnixUnsupported, + #[error("invalid extension endpoint: {0}")] + InvalidEndpoint(#[source] tonic::transport::Error), + #[error("could not configure extension TLS: {0}")] + Tls(#[source] tonic::transport::Error), + #[error("could not connect to extension service: {0}")] + Connect(#[source] tonic::transport::Error), +} + +pub async fn connect_channel(config: &ExtensionChannelConfig) -> Result { + validate_config(config)?; + if let Some(path) = config.endpoint.strip_prefix("unix://") { + return connect_unix(PathBuf::from(path)).await; + } + + let mut endpoint = standard_endpoint(&config.endpoint)?; + if config.endpoint.starts_with("https://") { + let tls = config.custom_ca_pem.as_ref().map_or_else( + || ClientTlsConfig::new().with_enabled_roots(), + |pem| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(pem)), + ); + endpoint = endpoint.tls_config(tls).map_err(TransportError::Tls)?; + } + endpoint.connect().await.map_err(TransportError::Connect) +} + +fn validate_config(config: &ExtensionChannelConfig) -> Result<(), TransportError> { + if config.endpoint.is_empty() { + return Err(TransportError::EmptyEndpoint); + } + let is_https = config.endpoint.starts_with("https://"); + let is_http = config.endpoint.starts_with("http://"); + let is_unix = config.endpoint.starts_with("unix://"); + if !is_https && !is_http && !is_unix { + return Err(TransportError::UnsupportedScheme); + } + if config.custom_ca_pem.is_some() && !is_https { + return Err(TransportError::CustomCaRequiresHttps); + } + if let Some(path) = config.endpoint.strip_prefix("unix://") + && (path.is_empty() || !PathBuf::from(path).is_absolute()) + { + return Err(TransportError::InvalidUnixPath); + } + Ok(()) +} + +fn standard_endpoint(uri: &str) -> Result { + Endpoint::from_shared(uri.to_string()) + .map(|endpoint| { + endpoint + .connect_timeout(CONNECT_TIMEOUT) + .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) + .keep_alive_while_idle(true) + .keep_alive_timeout(KEEP_ALIVE_TIMEOUT) + .http2_adaptive_window(true) + }) + .map_err(TransportError::InvalidEndpoint) +} + +#[cfg(unix)] +async fn connect_unix(path: PathBuf) -> Result { + standard_endpoint("http://[::]:50051")? + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(TransportError::Connect) +} + +#[cfg(not(unix))] +async fn connect_unix(_path: PathBuf) -> Result { + Err(TransportError::UnixUnsupported) +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::future::{Ready, ready}; + use std::task::{Context, Poll}; + + use rcgen::{BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair}; + use tokio::net::TcpListener; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::body::Body; + use tonic::server::NamedService; + use tonic::transport::{Identity, Server, ServerTlsConfig}; + use tower::Service; + + use super::*; + + #[derive(Clone)] + struct NoopGrpcService; + + impl NamedService for NoopGrpcService { + const NAME: &'static str = "openshell.test.Noop"; + } + + impl Service> for NoopGrpcService { + type Response = http::Response; + type Error = Infallible; + type Future = Ready>; + + fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _request: http::Request) -> Self::Future { + ready(Ok(http::Response::new(Body::empty()))) + } + } + + #[test] + fn accepts_supported_endpoint_forms() { + for endpoint in [ + "http://127.0.0.1:50051", + "https://middleware.example:443", + "unix:///run/openshell/middleware.sock", + ] { + validate_config(&ExtensionChannelConfig::new(endpoint)).unwrap(); + } + } + + #[test] + fn custom_ca_is_restricted_to_https() { + for endpoint in [ + "http://127.0.0.1:50051", + "unix:///run/openshell/middleware.sock", + ] { + let config = ExtensionChannelConfig::new(endpoint).with_custom_ca_pem(b"test CA"); + assert!(matches!( + validate_config(&config), + Err(TransportError::CustomCaRequiresHttps) + )); + } + validate_config( + &ExtensionChannelConfig::new("https://middleware.example") + .with_custom_ca_pem(b"test CA"), + ) + .unwrap(); + } + + #[test] + fn rejects_unsupported_schemes_and_relative_unix_paths() { + assert!(matches!( + validate_config(&ExtensionChannelConfig::new("tcp://middleware:50051")), + Err(TransportError::UnsupportedScheme) + )); + assert!(matches!( + validate_config(&ExtensionChannelConfig::new("unix://relative.sock")), + Err(TransportError::InvalidUnixPath) + )); + } + + #[test] + fn debug_does_not_render_ca_contents() { + let secretish_pem = b"private deployment CA material"; + let config = ExtensionChannelConfig::new("https://middleware.example") + .with_custom_ca_pem(secretish_pem); + assert!(!format!("{config:?}").contains("private deployment")); + } + + #[tokio::test] + async fn custom_ca_verifies_certificate_and_hostname() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).unwrap(); + + let server_key = KeyPair::generate().unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server_cert = server_params.signed_by(&server_key, &ca, &ca_key).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let incoming = TcpListenerStream::new(listener); + let tls = ServerTlsConfig::new().identity(Identity::from_pem( + server_cert.pem(), + server_key.serialize_pem(), + )); + tokio::spawn(async move { + Server::builder() + .tls_config(tls) + .unwrap() + .add_service(NoopGrpcService) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + let trusted = ExtensionChannelConfig::new(format!("https://localhost:{}", address.port())) + .with_custom_ca_pem(ca.pem()); + connect_channel(&trusted).await.unwrap(); + + let wrong_hostname = + ExtensionChannelConfig::new(format!("https://127.0.0.1:{}", address.port())) + .with_custom_ca_pem(ca.pem()); + assert!(matches!( + connect_channel(&wrong_hostname).await, + Err(TransportError::Connect(_)) + )); + + let rogue_key = KeyPair::generate().unwrap(); + let mut rogue_params = CertificateParams::new(Vec::::new()).unwrap(); + rogue_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let rogue_ca = rogue_params.self_signed(&rogue_key).unwrap(); + let wrong_ca = ExtensionChannelConfig::new(format!("https://localhost:{}", address.port())) + .with_custom_ca_pem(rogue_ca.pem()); + assert!(matches!( + connect_channel(&wrong_ca).await, + Err(TransportError::Connect(_)) + )); + } +} diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml index 7bcdd6eebf..f336562ec1 100644 --- a/crates/openshell-gateway-interceptors/Cargo.toml +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -12,8 +12,8 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } -hyper-util = { workspace = true, features = ["client", "http1", "http2", "tokio"] } json-patch = "1.4" metrics = { workspace = true } prost = { workspace = true } @@ -24,7 +24,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tonic = { workspace = true, features = ["channel", "tls-native-roots"] } -tower = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs index 32b4f3e525..d5bb5df59e 100644 --- a/crates/openshell-gateway-interceptors/src/lib.rs +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -11,7 +11,12 @@ #![allow(clippy::result_large_err)] +use std::collections::BTreeMap; + use openshell_core::config::GatewayInterceptorConfig; +use openshell_extension_core::{BearerTokenInterceptor, BearerTokenSlot}; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; pub(crate) mod plan; pub(crate) mod profile_source; @@ -38,13 +43,33 @@ pub enum InterceptorError { pub type Result = std::result::Result; +pub(crate) type ExtensionChannel = InterceptedService; + /// Return `None` when no interceptors are configured. pub async fn initialize( configs: Vec, +) -> Result> { + initialize_configured(configs, None).await +} + +/// Initialize gateway interceptors with rotating bearer-token slots. +/// +/// Every configured interceptor must have a corresponding slot. Slots may be +/// updated in place without rebuilding the execution plan or its gRPC clients. +pub async fn initialize_authenticated( + configs: Vec, + token_slots: BTreeMap, +) -> Result> { + initialize_configured(configs, Some(token_slots)).await +} + +async fn initialize_configured( + configs: Vec, + token_slots: Option>, ) -> Result> { if configs.is_empty() { return Ok(None); } - let runtime = GatewayInterceptorRuntime::build(configs).await?; + let runtime = GatewayInterceptorRuntime::build(configs, token_slots).await?; Ok(Some(runtime)) } diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs index b65d5612fd..e9859a4ecb 100644 --- a/crates/openshell-gateway-interceptors/src/plan.rs +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -4,10 +4,8 @@ //! Interceptor configuration and immutable execution planning. use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; use std::time::Duration; -use hyper_util::rt::TokioIo; use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, @@ -16,16 +14,15 @@ use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, gateway_interceptor_client::GatewayInterceptorClient, }; -use tokio::net::UnixStream; +use openshell_extension_core::{ + BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, connect_channel, +}; use tonic::Request; -use tonic::codegen::http::Uri; -use tonic::transport::{Channel, Endpoint}; -use tower::service_fn; use tracing::{info, warn}; use crate::profile_source::GatewayInterceptorProfileSource; use crate::routes::OpenShellRouteIndex; -use crate::{InterceptorError, Result}; +use crate::{ExtensionChannel, InterceptorError, Result}; pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; @@ -137,7 +134,7 @@ pub struct BindingPlan { pub(crate) timeout: Duration, pub(crate) max_response_bytes: usize, pub(crate) max_patches: usize, - pub(crate) client: GatewayInterceptorClient, + pub(crate) client: GatewayInterceptorClient, } impl std::fmt::Debug for BindingPlan { @@ -175,15 +172,30 @@ impl ExecutionPlan { pub(crate) async fn load( mut configs: Vec, routes: OpenShellRouteIndex, + token_slots: Option>, ) -> Result { validate_interceptor_configs(&configs)?; + validate_authenticated_slots(&configs, token_slots.as_ref())?; configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); let mut profile_sources = BTreeMap::new(); for config in configs { - let channel = connect_endpoint(&config.grpc_endpoint).await?; + let channel = connect_endpoint(&config).await?; + let interceptor = match token_slots.as_ref() { + Some(slots) => slots + .get(&config.name) + .ok_or_else(|| { + InterceptorError::Config(format!( + "authenticated interceptor '{}' is missing a bearer-token slot", + config.name + )) + })? + .interceptor(), + None => BearerTokenInterceptor::disabled(), + }; + let channel = ExtensionChannel::new(channel, interceptor); let timeout = match config.timeout.as_deref() { Some(timeout) => parse_duration(timeout)?, None => DEFAULT_TIMEOUT, @@ -348,6 +360,24 @@ impl ExecutionPlan { } } +fn validate_authenticated_slots( + configs: &[GatewayInterceptorConfig], + token_slots: Option<&BTreeMap>, +) -> Result<()> { + let Some(token_slots) = token_slots else { + return Ok(()); + }; + for config in configs { + if !token_slots.contains_key(&config.name) { + return Err(InterceptorError::Config(format!( + "authenticated interceptor '{}' is missing a bearer-token slot", + config.name + ))); + } + } + Ok(()) +} + #[derive(Debug, Clone)] struct NormalizedBinding { binding_id: String, @@ -857,42 +887,31 @@ pub fn parse_duration(value: &str) -> Result { ))) } -async fn connect_endpoint(endpoint: &str) -> Result { - let endpoint = endpoint.trim(); - if let Some(path) = endpoint.strip_prefix("unix://") { - return connect_unix_endpoint(PathBuf::from(path)).await; +async fn connect_endpoint(config: &GatewayInterceptorConfig) -> Result { + let endpoint = config.grpc_endpoint.trim(); + let mut channel_config = ExtensionChannelConfig::new(endpoint); + if let Some(path) = &config.tls_ca_cert_path { + let pem = tokio::fs::read(path).await.map_err(|error| { + InterceptorError::Config(format!( + "failed to read TLS CA certificate for interceptor '{}' from {}: {error}", + config.name, + path.display() + )) + })?; + channel_config = channel_config.with_custom_ca_pem(pem); } - Endpoint::from_shared(endpoint.to_string()) - .map_err(|e| { - InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) - })? - .connect() - .await - .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) -} - -#[cfg(unix)] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - let display = path.display().to_string(); - Endpoint::from_static("http://[::]:50051") - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - async move { UnixStream::connect(path).await.map(TokioIo::new) } - })) - .await - .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) -} - -#[cfg(not(unix))] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - Err(InterceptorError::Config(format!( - "unix interceptor endpoints are not supported on this platform: {}", - path.display() - ))) + connect_channel(&channel_config).await.map_err(|error| { + InterceptorError::Transport(format!( + "connect interceptor '{}' at {endpoint}: {error}", + config.name + )) + }) } #[cfg(test)] mod tests { + use std::path::PathBuf; + use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorPhaseConfig, @@ -963,6 +982,41 @@ mod tests { ); } + #[test] + fn authenticated_interceptors_require_a_token_slot_per_registration() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + ..GatewayInterceptorConfig::default() + }; + let error = + validate_authenticated_slots(std::slice::from_ref(&config), Some(&BTreeMap::new())) + .expect_err("missing token slot must fail closed"); + assert_eq!( + error.to_string(), + "invalid interceptor config: authenticated interceptor 'governance' is missing a bearer-token slot" + ); + + let slots = BTreeMap::from([(config.name.clone(), BearerTokenSlot::empty())]); + validate_authenticated_slots(&[config], Some(&slots)).unwrap(); + } + + #[tokio::test] + async fn configured_ca_read_failure_names_interceptor_without_certificate_contents() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "https://governance.example".to_string(), + tls_ca_cert_path: Some(PathBuf::from("/definitely/missing/openshell-ca.pem")), + ..GatewayInterceptorConfig::default() + }; + let error = connect_endpoint(&config) + .await + .expect_err("missing CA must prevent connection"); + let message = error.to_string(); + assert!(message.contains("governance")); + assert!(message.contains("/definitely/missing/openshell-ca.pem")); + } + #[test] fn interceptor_binding_policy_defaults_to_dynamic() { assert_eq!( diff --git a/crates/openshell-gateway-interceptors/src/profile_source.rs b/crates/openshell-gateway-interceptors/src/profile_source.rs index 74cd3b26c2..48014258a2 100644 --- a/crates/openshell-gateway-interceptors/src/profile_source.rs +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -11,9 +11,9 @@ use openshell_core::proto::gateway_interceptor::v1::{ }; use prost::Message as _; use sha2::Digest as _; -use tonic::{Request, transport::Channel}; +use tonic::Request; -use crate::{InterceptorError, Result}; +use crate::{ExtensionChannel, InterceptorError, Result}; #[derive(Debug, Clone)] pub struct ProviderProfileSourceSnapshot { @@ -26,7 +26,7 @@ pub struct GatewayInterceptorProfileSource { interceptor_name: String, source_id: String, timeout: Duration, - client: GatewayInterceptorClient, + client: GatewayInterceptorClient, } impl GatewayInterceptorProfileSource { @@ -34,7 +34,7 @@ impl GatewayInterceptorProfileSource { interceptor_name: String, source_id: String, timeout: Duration, - client: GatewayInterceptorClient, + client: GatewayInterceptorClient, ) -> Self { Self { interceptor_name, diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index 7938644b66..f6aecbcf67 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -371,6 +371,7 @@ mod tests { ("openshell.compute.v1.DriverSandboxSpec", "sandbox_token"), ("openshell.v1.IssueSandboxTokenResponse", "token"), ("openshell.v1.RefreshSandboxTokenResponse", "token"), + ("openshell.v1.ExtensionServiceCredential", "token"), ("openshell.v1.CreateSshSessionResponse", "token"), ("openshell.v1.RevokeSshSessionRequest", "token"), ("openshell.v1.TcpForwardInit", "authorization_token"), diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index d510956aed..4e7f5ba613 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -14,6 +14,7 @@ use openshell_core::proto::gateway_interceptor::v1::{ InterceptorEvaluation, InterceptorResult, JsonPatch, ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation, interceptor_evaluation, }; +use openshell_extension_core::BearerTokenSlot; use prost::Message as _; use prost_types::Struct; use serde_json::{Map, Value}; @@ -77,10 +78,13 @@ impl ValidatedOperation { } impl GatewayInterceptorRuntime { - pub(crate) async fn build(configs: Vec) -> Result { + pub(crate) async fn build( + configs: Vec, + token_slots: Option>, + ) -> Result { let codec = ProtoJsonCodec::openshell()?; let routes = routes::OpenShellRouteIndex::from_descriptor_pool(codec.descriptor_pool())?; - let plan = ExecutionPlan::load(configs, routes).await?; + let plan = ExecutionPlan::load(configs, routes, token_slots).await?; Ok(Self { plan: Arc::new(plan), codec, @@ -602,6 +606,7 @@ mod tests { CreateProviderRequest, CreateSandboxRequest, Provider, SandboxSpec, SandboxTemplate, UpdateConfigRequest, }; + use openshell_extension_core::BearerTokenInterceptor; use serde_json::json; use std::collections::HashMap; use std::sync::{ @@ -724,8 +729,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), } } @@ -923,8 +929,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), }; let result = InterceptorResult { @@ -1107,8 +1114,9 @@ mod tests { timeout: DEFAULT_TIMEOUT, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_patches: DEFAULT_MAX_PATCHES, - client: GatewayInterceptorClient::new( + client: GatewayInterceptorClient::with_interceptor( Channel::from_static("http://127.0.0.1:1").connect_lazy(), + BearerTokenInterceptor::disabled(), ), }; let operation = json!({ "name": "demo" }); diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 94cbb4ad51..70603524a8 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-supervisor-network = { path = "../openshell-supervisor-network", default-features = false } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 956fed927c..26d81f717f 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -2068,10 +2068,20 @@ async fn load_policy( let middleware_registry_status = if middleware_services.is_empty() { MiddlewareRegistryStatus::Synchronized } else if let Err(error) = grpc_retry("Middleware connect", || { - openshell_supervisor_middleware::MiddlewareRegistry::connect_services( - openshell_supervisor_middleware_builtins::services(), - middleware_services.clone(), - ) + let middleware_services = middleware_services.clone(); + async move { + let credentials = openshell_core::grpc_client::refresh_extension_credentials( + endpoint, + &middleware_services, + ) + .await?; + openshell_supervisor_middleware::MiddlewareRegistry::connect_services_authenticated( + openshell_supervisor_middleware_builtins::services(), + middleware_services, + &credentials, + ) + .await + } }) .await .and_then(|registry| engine.replace_middleware_registry(registry)) @@ -2282,13 +2292,18 @@ async fn reload_gateway_policy_runtime( policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, desired_services: &[openshell_core::proto::SupervisorMiddlewareService], + middleware_credentials: &std::collections::HashMap< + String, + openshell_extension_core::BearerTokenSlot, + >, middleware_registry_changed: bool, ) -> std::result::Result<(), GatewayRuntimeReloadError> { match policy { Some(policy) if middleware_registry_changed => { - let registry = connect_middleware_registry(desired_services) - .await - .map_err(GatewayRuntimeReloadError::MiddlewareRegistry)?; + let registry = + connect_middleware_registry_authenticated(desired_services, middleware_credentials) + .await + .map_err(GatewayRuntimeReloadError::MiddlewareRegistry)?; engine .reload_policy_and_middleware_from_proto_with_pid(policy, entrypoint_pid, registry) .map_err(GatewayRuntimeReloadError::PolicyValidation) @@ -2641,6 +2656,7 @@ struct PolicyPollLoopContext { workspace_tx: tokio::sync::watch::Sender, } +#[cfg(test)] async fn connect_middleware_registry( services: &[openshell_core::proto::SupervisorMiddlewareService], ) -> Result { @@ -2651,6 +2667,18 @@ async fn connect_middleware_registry( .await } +async fn connect_middleware_registry_authenticated( + services: &[openshell_core::proto::SupervisorMiddlewareService], + credentials: &std::collections::HashMap, +) -> Result { + openshell_supervisor_middleware::MiddlewareRegistry::connect_services_authenticated( + openshell_supervisor_middleware_builtins::services(), + services.to_vec(), + credentials, + ) + .await +} + async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<()> { let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), @@ -2663,6 +2691,7 @@ async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<( async fn reconcile_middleware_registry( opa_engine: &OpaEngine, desired_services: &[openshell_core::proto::SupervisorMiddlewareService], + credentials: &std::collections::HashMap, current_services: &mut Vec, status: &mut MiddlewareRegistryStatus, ) { @@ -2672,11 +2701,12 @@ async fn reconcile_middleware_registry( return; } - match connect_middleware_registry(desired_services) + match connect_middleware_registry_authenticated(desired_services, credentials) .await .and_then(|registry| opa_engine.replace_middleware_registry(registry)) { Ok(()) => { + openshell_core::grpc_client::retain_extension_credentials(desired_services); current_services.clear(); current_services.extend_from_slice(desired_services); *status = MiddlewareRegistryStatus::Synchronized; @@ -2991,7 +3021,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { let result = if let Some(result) = pending_result.take() { result } else { - tokio::time::sleep(interval).await; + tokio::time::sleep( + openshell_core::grpc_client::extension_credential_refresh_delay(interval), + ) + .await; match client.poll_settings(&ctx.sandbox_id).await { Ok(result) => { let _ = ctx.workspace_tx.send(client.workspace()); @@ -2999,11 +3032,33 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } Err(e) => { debug!(error = %e, "Settings poll: server unreachable, will retry"); + if let Err(refresh_error) = + client.refresh_installed_extension_credentials().await + { + warn!( + error = %refresh_error, + "Settings poll: extension credential refresh failed while configuration was unavailable" + ); + } continue; } } }; + // Refresh per-service credentials on the existing gateway channel. + // Existing middleware clients retain the same slots, so successful + // rotation is independent of config revision and registry equality. + let middleware_credentials = match client + .refresh_extension_credentials(&result.supervisor_middleware_services) + .await + { + Ok(credentials) => credentials, + Err(error) => { + warn!(error = %error, "Settings poll: extension credential refresh failed"); + std::collections::HashMap::new() + } + }; + let config_changed = result.config_revision != current_config_revision; let provider_env_changed = result.provider_env_revision != current_provider_env_revision; let policy_changed = result.policy_hash != current_policy_hash; @@ -3038,6 +3093,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { reconcile_middleware_registry( &ctx.opa_engine, &result.supervisor_middleware_services, + &middleware_credentials, &mut current_middleware_services, &mut middleware_registry_status, ) @@ -3147,6 +3203,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { result.policy.as_ref(), pid, &result.supervisor_middleware_services, + &middleware_credentials, middleware_registry_changed, ) .await; @@ -3243,6 +3300,9 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { current_policy_hash.clone_from(&result.policy_hash); current_middleware_services.clone_from(&result.supervisor_middleware_services); + openshell_core::grpc_client::retain_extension_credentials( + &result.supervisor_middleware_services, + ); middleware_registry_status = MiddlewareRegistryStatus::Synchronized; last_failed_runtime_revision = None; } @@ -3884,6 +3944,7 @@ filesystem_policy: Some(&proto_policy_fixture()), 0, &[unavailable_service], + &std::collections::HashMap::new(), true, ) .await diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 7182d0f702..482408f415 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -23,6 +23,7 @@ openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-kubernetes-secrets = { path = "../openshell-driver-kubernetes-secrets" } openshell-driver-vault = { path = "../openshell-driver-vault" } openshell-driver-podman = { path = "../openshell-driver-podman" } +openshell-extension-core = { path = "../openshell-extension-core" } openshell-gateway-interceptors = { path = "../openshell-gateway-interceptors" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-otel = { path = "../openshell-otel" } diff --git a/crates/openshell-server/src/auth/http.rs b/crates/openshell-server/src/auth/http.rs index f0e7011658..a1ce2e2867 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -59,9 +59,28 @@ pub fn router(state: Arc) -> Router { Router::new() .route("/auth/connect", get(auth_connect)) .route("/auth/oidc-config", get(oidc_config_handler)) + .route("/.well-known/jwks.json", get(gateway_jwks_handler)) .with_state(state) } +/// Publish the gateway's JWT verification key for extension services. +/// +/// Public keys are not secret. The HTTPS connection authenticates the +/// gateway from which an integration bootstraps this document; integrations +/// then cache keys by `kid` and refresh when an unfamiliar `kid` appears. +async fn gateway_jwks_handler(State(state): State>) -> impl IntoResponse { + gateway_jwks_response(state.sandbox_jwt_authenticator.as_deref()) +} + +fn gateway_jwks_response( + authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>, +) -> axum::response::Response { + authenticator.map_or_else( + || StatusCode::NOT_FOUND.into_response(), + |authenticator| Json(authenticator.jwks()).into_response(), + ) +} + /// OIDC configuration discovery endpoint. /// /// Returns the OIDC issuer and audience when OIDC is configured on the server, @@ -474,6 +493,7 @@ fn render_waiting_page(callback_port: u16, code: &str) -> String { #[cfg(test)] mod tests { use super::*; + use openshell_bootstrap::jwt::generate_jwt_key; #[test] fn extract_cookie_finds_value() { @@ -586,4 +606,29 @@ mod tests { assert_eq!(html_escape("a\"b"), "a"b"); assert_eq!(html_escape("a'b"), "a'b"); } + + #[test] + fn jwks_response_publishes_configured_gateway_key() { + let material = generate_jwt_key().expect("key"); + let authenticator = crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem( + material.public_key_pem.as_bytes(), + material.kid.clone(), + "gateway-a", + ) + .expect("authenticator"); + + let response = gateway_jwks_response(Some(&authenticator)); + assert_eq!(response.status(), StatusCode::OK); + let key = &authenticator.jwks().keys[0]; + assert_eq!(key.kid, material.kid); + assert_eq!(key.kty, "OKP"); + assert_eq!(key.crv, "Ed25519"); + assert_eq!(key.alg, "EdDSA"); + assert_eq!(key.key_use, "sig"); + } + + #[test] + fn jwks_response_is_not_found_without_gateway_key() { + assert_eq!(gateway_jwks_response(None).status(), StatusCode::NOT_FOUND); + } } diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 39f5982ca0..eb500283ee 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -18,13 +18,21 @@ use super::authenticator::Authenticator; use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, }; +pub use openshell_extension_core::{ + ExtensionAudience, ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL, +}; use serde::{Deserialize, Serialize}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + io::Cursor, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use tonic::Status; use tracing::{debug, warn}; +use x509_parser::{oid_registry::OID_SIG_ED25519, prelude::FromDer, x509::SubjectPublicKeyInfo}; /// SPIFFE-shaped subject prefix. Embedded in the `sub` claim of every /// minted token so a future migration to per-sandbox certs or SPIRE can @@ -33,6 +41,24 @@ use tracing::{debug, warn}; const SPIFFE_SUBJECT_PREFIX: &str = "spiffe://openshell/sandbox/"; const SANDBOX_JWT_EXP_LEEWAY_SECS: i64 = 60; +/// Public JSON Web Key Set served by the gateway. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewayJwks { + pub keys: Vec, +} + +/// Ed25519 public key entry in the gateway JWKS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewayJwk { + pub kty: &'static str, + pub crv: &'static str, + pub alg: &'static str, + #[serde(rename = "use")] + pub key_use: &'static str, + pub kid: String, + pub x: String, +} + /// JWT claim set serialized in every gateway-minted sandbox token. #[derive(Debug, Serialize, Deserialize)] pub struct SandboxJwtClaims { @@ -126,6 +152,67 @@ impl SandboxJwtIssuer { }) } + /// Mint a short-lived bearer token for one exact extension audience. + /// + /// `sandbox_id` is required for supervisor calls and forbidden for + /// gateway calls. The subject follows the existing SPIFFE-shaped sandbox + /// identity for supervisor calls; gateway calls use the issuer identity. + #[allow(clippy::result_large_err)] + pub fn mint_extension_token( + &self, + audience: &ExtensionAudience, + caller_kind: ExtensionCallerKind, + sandbox_id: Option<&str>, + ttl: Duration, + ) -> Result { + if ttl.is_zero() || ttl > MAX_EXTENSION_TOKEN_TTL { + return Err(Status::invalid_argument(format!( + "extension token TTL must be between 1 and {} seconds", + MAX_EXTENSION_TOKEN_TTL.as_secs() + ))); + } + + let (sub, sandbox_id) = match (caller_kind, sandbox_id) { + (ExtensionCallerKind::Gateway, None) => (self.issuer.clone(), None), + (ExtensionCallerKind::Supervisor, Some(id)) if !id.trim().is_empty() => { + (format!("{SPIFFE_SUBJECT_PREFIX}{id}"), Some(id.to_string())) + } + (ExtensionCallerKind::Gateway, Some(_)) => { + return Err(Status::invalid_argument( + "gateway extension tokens must not include a sandbox ID", + )); + } + (ExtensionCallerKind::Supervisor, _) => { + return Err(Status::invalid_argument( + "supervisor extension tokens require a sandbox ID", + )); + } + }; + + let now = now_secs(); + let exp = now.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(3_600)); + let claims = ExtensionJwtClaims { + iss: self.issuer.clone(), + aud: audience.as_str().to_string(), + sub, + iat: now, + exp, + jti: uuid::Uuid::new_v4().to_string(), + caller_kind, + sandbox_id, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + let token = encode(&header, &claims, &self.encoding_key).map_err(|e| { + warn!(error = %e, "failed to mint extension JWT"); + Status::internal("failed to mint extension token") + })?; + Ok(MintedToken { + token, + expires_at_ms: exp.saturating_mul(1000), + }) + } + pub fn ttl(&self) -> Duration { self.ttl } @@ -137,6 +224,7 @@ pub struct SandboxJwtAuthenticator { kid: String, issuer: String, audience: String, + jwks: GatewayJwks, } impl std::fmt::Debug for SandboxJwtAuthenticator { @@ -153,15 +241,24 @@ impl SandboxJwtAuthenticator { pub fn from_pem(public_key_pem: &[u8], kid: String, gateway_id: &str) -> Result { let decoding_key = DecodingKey::from_ed_pem(public_key_pem) .map_err(|e| format!("failed to parse Ed25519 public key PEM: {e}"))?; + let jwks = GatewayJwks::from_public_key_pem(public_key_pem, kid.clone())?; let identity = format!("openshell-gateway:{gateway_id}"); Ok(Self { decoding_key, kid, issuer: identity.clone(), audience: identity, + jwks, }) } + /// Return the public signing keys integrations use to verify extension + /// tokens. No private key material is retained by this type. + #[must_use] + pub const fn jwks(&self) -> &GatewayJwks { + &self.jwks + } + #[allow(clippy::result_large_err)] fn validate_bearer(&self, token: &str) -> Result, Status> { let header = decode_header(token).map_err(|e| { @@ -201,6 +298,36 @@ impl SandboxJwtAuthenticator { } } +impl GatewayJwks { + fn from_public_key_pem(public_key_pem: &[u8], kid: String) -> Result { + let item = rustls_pemfile::read_one(&mut Cursor::new(public_key_pem)) + .map_err(|e| format!("failed to parse Ed25519 public key PEM for JWKS: {e}"))?; + let Some(rustls_pemfile::Item::SubjectPublicKeyInfo(der)) = item else { + return Err("Ed25519 public key PEM does not contain a PUBLIC KEY block".into()); + }; + let (remainder, spki) = SubjectPublicKeyInfo::from_der(der.as_ref()) + .map_err(|e| format!("failed to parse SubjectPublicKeyInfo for JWKS: {e}"))?; + if !remainder.is_empty() || spki.algorithm.algorithm != OID_SIG_ED25519 { + return Err("public key is not an RFC 8410 Ed25519 SubjectPublicKeyInfo key".into()); + } + let raw_key = spki.subject_public_key.data.as_ref(); + if raw_key.len() != 32 { + return Err("Ed25519 public key must be 32 bytes".to_string()); + } + + Ok(Self { + keys: vec![GatewayJwk { + kty: "OKP", + crv: "Ed25519", + alg: "EdDSA", + key_use: "sig", + kid, + x: URL_SAFE_NO_PAD.encode(raw_key), + }], + }) + } +} + #[async_trait] impl Authenticator for SandboxJwtAuthenticator { async fn authenticate( @@ -278,6 +405,10 @@ mod tests { (issuer, auth) } + fn extension_audience(value: &str) -> ExtensionAudience { + ExtensionAudience::new(value).expect("valid extension audience") + } + #[tokio::test] async fn mint_and_validate_round_trip() { let (issuer, auth) = pair(); @@ -393,4 +524,143 @@ mod tests { .expect_err("expired token must reject"); assert_eq!(err.code(), tonic::Code::Unauthenticated); } + + #[test] + fn extension_tokens_have_exact_audience_and_caller_identity() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid.clone(), + "gateway-a", + Duration::ZERO, + ) + .expect("issuer"); + let decoding_key = DecodingKey::from_ed_pem(mat.public_key_pem.as_bytes()).unwrap(); + + let gateway = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(300), + ) + .expect("gateway token"); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&["openshell-gateway:gateway-a"]); + validation.set_audience(&["urn:openshell:extension:middleware:scanner"]); + validation.set_required_spec_claims(&["iss", "aud", "sub", "iat", "exp"]); + let claims = decode::(&gateway.token, &decoding_key, &validation) + .expect("valid extension token") + .claims; + assert_eq!(claims.sub, "openshell-gateway:gateway-a"); + assert_eq!(claims.caller_kind, ExtensionCallerKind::Gateway); + assert_eq!(claims.sandbox_id, None); + assert!(!claims.jti.is_empty()); + assert_eq!(gateway.expires_at_ms, claims.exp * 1000); + + let supervisor = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Supervisor, + Some("sandbox-a"), + Duration::from_secs(300), + ) + .expect("supervisor token"); + let claims = decode::(&supervisor.token, &decoding_key, &validation) + .expect("valid supervisor extension token") + .claims; + assert_eq!(claims.sub, "spiffe://openshell/sandbox/sandbox-a"); + assert_eq!(claims.caller_kind, ExtensionCallerKind::Supervisor); + assert_eq!(claims.sandbox_id.as_deref(), Some("sandbox-a")); + } + + #[test] + fn extension_token_rejects_wrong_audience() { + let mat = generate_jwt_key().expect("jwt key"); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid, + "gateway-a", + Duration::ZERO, + ) + .expect("issuer"); + let minted = issuer + .mint_extension_token( + &extension_audience("service-a"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(60), + ) + .expect("token"); + let decoding_key = DecodingKey::from_ed_pem(mat.public_key_pem.as_bytes()).unwrap(); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&["openshell-gateway:gateway-a"]); + validation.set_audience(&["service-b"]); + assert!(decode::(&minted.token, &decoding_key, &validation).is_err()); + } + + #[test] + fn extension_token_enforces_positive_bounded_ttl_and_caller_shape() { + let (issuer, _) = pair_with_ttl(Duration::ZERO); + for ttl in [ + Duration::ZERO, + MAX_EXTENSION_TOKEN_TTL + Duration::from_secs(1), + ] { + let error = issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Gateway, + None, + ttl, + ) + .expect_err("invalid TTL"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + assert!( + issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Supervisor, + None, + Duration::from_secs(60), + ) + .is_err() + ); + assert!( + issuer + .mint_extension_token( + &extension_audience("service"), + ExtensionCallerKind::Gateway, + Some("sandbox-a"), + Duration::from_secs(60), + ) + .is_err() + ); + assert!(ExtensionAudience::new(" ").is_err()); + } + + #[test] + fn jwks_contains_public_ed25519_key_without_pem_material() { + let mat = generate_jwt_key().expect("jwt key"); + let auth = SandboxJwtAuthenticator::from_pem( + mat.public_key_pem.as_bytes(), + mat.kid.clone(), + "gateway-a", + ) + .expect("authenticator"); + let jwks = auth.jwks(); + assert_eq!(jwks.keys.len(), 1); + let key = &jwks.keys[0]; + assert_eq!(key.kid, mat.kid); + assert_eq!(key.kty, "OKP"); + assert_eq!(key.crv, "Ed25519"); + assert_eq!(key.alg, "EdDSA"); + assert_eq!(key.key_use, "sig"); + assert_eq!(URL_SAFE_NO_PAD.decode(&key.x).unwrap().len(), 32); + + let json = serde_json::to_string(jwks).expect("JSON"); + assert!(!json.contains("BEGIN PUBLIC KEY")); + assert!(!json.contains("PRIVATE")); + assert!(json.contains(r#""use":"sig""#)); + } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 3e984a891c..9fa05bab86 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -21,9 +21,11 @@ //! values. use std::collections::BTreeMap; +use std::io::Cursor; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use base64::Engine as _; use openshell_core::config::ComputeDriverKind; use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{ @@ -220,8 +222,15 @@ pub struct SupervisorFileSection { pub struct MiddlewareServiceFileConfig { /// Operator-facing name used for diagnostics. pub name: String, - /// Plaintext gRPC endpoint reachable by the gateway and supervisors. + /// HTTP or HTTPS gRPC endpoint reachable by the gateway and supervisors. pub grpc_endpoint: String, + /// Optional PEM trust-root bundle for an HTTPS endpoint. + #[serde(default)] + pub tls_ca_cert_path: Option, + /// Exact JWT audience for this service. Defaults to a kind-scoped value + /// derived from the registration name. + #[serde(default)] + pub audience: Option, /// Operator-owned body limit for every binding exposed by this service. pub max_body_bytes: u64, /// Default RPC timeout using an integer with an `ms` or `s` suffix. @@ -229,15 +238,74 @@ pub struct MiddlewareServiceFileConfig { pub timeout: Option, } -impl From<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { - fn from(config: &MiddlewareServiceFileConfig) -> Self { - Self { +impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { + type Error = ConfigFileError; + + fn try_from(config: &MiddlewareServiceFileConfig) -> Result { + let tls_ca_cert_pem = match &config.tls_ca_cert_path { + Some(path) => { + let pem = + std::fs::read(path).map_err(|source| ConfigFileError::MiddlewareTlsCaRead { + name: config.name.clone(), + path: path.clone(), + source, + })?; + sanitize_ca_cert_pem(&config.name, path, &pem)? + } + None => Vec::new(), + }; + + Ok(Self { name: config.name.clone(), grpc_endpoint: config.grpc_endpoint.clone(), max_body_bytes: config.max_body_bytes, timeout: config.timeout.clone().unwrap_or_default(), + tls_ca_cert_pem, + audience: config + .audience + .as_deref() + .filter(|audience| !audience.is_empty()) + .map_or_else( + || format!("urn:openshell:extension:middleware:{}", config.name), + ToString::to_string, + ), + }) + } +} + +fn sanitize_ca_cert_pem(name: &str, path: &Path, pem: &[u8]) -> Result, ConfigFileError> { + let mut sanitized = Vec::new(); + let mut certificate_count = 0; + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + let item = item.map_err(|source| ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: source.to_string(), + })?; + let rustls_pemfile::Item::X509Certificate(certificate) = item else { + return Err(ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: "PEM bundle contains a non-certificate block".to_string(), + }); + }; + certificate_count += 1; + sanitized.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); + let encoded = base64::engine::general_purpose::STANDARD.encode(certificate.as_ref()); + for line in encoded.as_bytes().chunks(64) { + sanitized.extend_from_slice(line); + sanitized.push(b'\n'); } + sanitized.extend_from_slice(b"-----END CERTIFICATE-----\n"); } + if certificate_count == 0 { + return Err(ConfigFileError::MiddlewareTlsCaInvalid { + name: name.to_string(), + path: path.to_path_buf(), + message: "PEM bundle does not contain a certificate".to_string(), + }); + } + Ok(sanitized) } #[derive(Debug, thiserror::Error)] @@ -271,6 +339,25 @@ pub enum ConfigFileError { field: &'static str, message: &'static str, }, + #[error( + "failed to read TLS CA certificate for supervisor middleware '{name}' from '{}': {source}", + path.display() + )] + MiddlewareTlsCaRead { + name: String, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( + "invalid TLS CA certificate for supervisor middleware '{name}' at '{}': {message}", + path.display() + )] + MiddlewareTlsCaInvalid { + name: String, + path: PathBuf, + message: String, + }, } /// Load and validate a TOML config file. @@ -604,27 +691,155 @@ allow_unauthenticated_users = true #[test] fn parses_supervisor_middleware_registration() { + let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("test certificate"); + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(certificate.cert.pem().as_bytes()) + .expect("write CA"); let toml = r#" [[openshell.supervisor.middleware]] name = "local-guard" -grpc_endpoint = "http://127.0.0.1:50051" +grpc_endpoint = "https://127.0.0.1:50051" +tls_ca_cert_path = "CA_PATH" +audience = "urn:openshell:middleware:local-guard" max_body_bytes = 262144 timeout = "2s" -"#; - let tmp = write_tmp(toml); +"# + .replace("CA_PATH", &ca.path().display().to_string()); + let tmp = write_tmp(&toml); let file = load(tmp.path()).expect("valid middleware registration parses"); assert_eq!( file.openshell.supervisor.middleware, vec![MiddlewareServiceFileConfig { name: "local-guard".into(), - grpc_endpoint: "http://127.0.0.1:50051".into(), + grpc_endpoint: "https://127.0.0.1:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: Some("urn:openshell:middleware:local-guard".into()), max_body_bytes: 262_144, timeout: Some("2s".into()), }] ); let registration = - SupervisorMiddlewareService::from(&file.openshell.supervisor.middleware[0]); + SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) + .expect("valid CA resolves"); assert_eq!(registration.timeout, "2s"); + assert_eq!( + registration.tls_ca_cert_pem, + certificate.cert.pem().as_bytes() + ); + assert_eq!( + registration.audience, + "urn:openshell:middleware:local-guard" + ); + } + + #[test] + fn middleware_registration_defaults_audience_to_name() { + let mut config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: None, + audience: None, + max_body_bytes: 262_144, + timeout: None, + }; + + let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); + assert_eq!( + registration.audience, + "urn:openshell:extension:middleware:local-guard" + ); + assert!(registration.tls_ca_cert_pem.is_empty()); + + config.audience = Some(String::new()); + let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); + assert_eq!( + registration.audience, + "urn:openshell:extension:middleware:local-guard" + ); + } + + #[test] + fn middleware_registration_rejects_invalid_ca_pem() { + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(b"not a certificate").expect("write CA"); + let config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: None, + max_body_bytes: 262_144, + timeout: None, + }; + + let error = SupervisorMiddlewareService::try_from(&config) + .expect_err("invalid CA must fail before service connection"); + assert!(matches!( + error, + ConfigFileError::MiddlewareTlsCaInvalid { .. } + )); + } + + #[test] + fn middleware_registration_rejects_ca_bundle_with_private_key() { + use std::io::Write as _; + + let certificate = + rcgen::generate_simple_self_signed(vec!["localhost".into()]).expect("test certificate"); + let mut ca = tempfile::Builder::new() + .suffix(".pem") + .tempfile() + .expect("CA tempfile"); + ca.write_all(certificate.cert.pem().as_bytes()) + .expect("write certificate"); + ca.write_all(certificate.key_pair.serialize_pem().as_bytes()) + .expect("write private key"); + let config = MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "https://guard.example:50051".into(), + tls_ca_cert_path: Some(ca.path().to_path_buf()), + audience: None, + max_body_bytes: 262_144, + timeout: None, + }; + + let error = SupervisorMiddlewareService::try_from(&config) + .expect_err("private key material must never be distributed to a sandbox"); + assert!(matches!( + error, + ConfigFileError::MiddlewareTlsCaInvalid { .. } + )); + assert!(error.to_string().contains("non-certificate block")); + } + + #[test] + fn parses_gateway_interceptor_tls_and_audience() { + let tmp = write_tmp( + r#" +[[openshell.gateway.interceptors]] +name = "quota" +grpc_endpoint = "https://quota.example:50051" +tls_ca_cert_path = "/etc/openshell/quota-ca.pem" +audience = "urn:openshell:interceptor:quota" +"#, + ); + + let file = load(tmp.path()).expect("valid interceptor config parses"); + let interceptor = &file.openshell.gateway.interceptors[0]; + assert_eq!( + interceptor.tls_ca_cert_path.as_deref(), + Some(Path::new("/etc/openshell/quota-ca.pem")) + ); + assert_eq!( + interceptor.resolved_audience(), + "urn:openshell:interceptor:quota" + ); } #[test] diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 84ec9b97e4..c22c69658f 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -16,10 +16,14 @@ use crate::ServerState; use crate::auth::identity::IdentityProvider; use crate::auth::principal::{Principal, SandboxIdentitySource}; use openshell_core::proto::{ - GetCurrentUserRequest, GetCurrentUserResponse, IssueSandboxTokenRequest, - IssueSandboxTokenResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, Sandbox, + ExtensionServiceCredential, GetCurrentUserRequest, GetCurrentUserResponse, + GetSandboxConfigRequest, IssueSandboxTokenRequest, IssueSandboxTokenResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, Sandbox, }; +use openshell_extension_core::{ExtensionAudience, ExtensionCallerKind, MAX_EXTENSION_TOKEN_TTL}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; @@ -109,6 +113,7 @@ pub async fn handle_refresh_sandbox_token( state: &Arc, request: Request, ) -> Result, Status> { + let requested_extension_services = request.get_ref().extension_service_names.clone(); let principal = request .extensions() .get::() @@ -144,6 +149,26 @@ pub async fn handle_refresh_sandbox_token( ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; let minted = issuer.mint(&sandbox.sandbox_id)?; + let extension_credentials = if requested_extension_services.is_empty() { + Vec::new() + } else { + let mut config_request = Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox.sandbox_id.clone(), + }); + config_request + .extensions_mut() + .insert(Principal::Sandbox(sandbox.clone())); + let available = super::policy::handle_get_sandbox_config(state, config_request) + .await? + .into_inner() + .supervisor_middleware_services; + mint_extension_credentials( + issuer, + &sandbox.sandbox_id, + &requested_extension_services, + &available, + )? + }; info!( sandbox_id = %sandbox.sandbox_id, "renewed gateway sandbox JWT" @@ -152,9 +177,75 @@ pub async fn handle_refresh_sandbox_token( Ok(Response::new(RefreshSandboxTokenResponse { token: minted.token, expires_at_ms: minted.expires_at_ms, + extension_credentials, })) } +const MAX_EXTENSION_CREDENTIALS_PER_REFRESH: usize = 64; +const DEFAULT_EXTENSION_TOKEN_TTL: Duration = Duration::from_secs(15 * 60); + +#[allow(clippy::result_large_err)] +fn mint_extension_credentials( + issuer: &crate::auth::sandbox_jwt::SandboxJwtIssuer, + sandbox_id: &str, + requested_names: &[String], + available_services: &[openshell_core::proto::SupervisorMiddlewareService], +) -> Result, Status> { + if requested_names.len() > MAX_EXTENSION_CREDENTIALS_PER_REFRESH { + return Err(Status::invalid_argument(format!( + "at most {MAX_EXTENSION_CREDENTIALS_PER_REFRESH} extension credentials may be requested" + ))); + } + let mut unique = HashSet::with_capacity(requested_names.len()); + for name in requested_names { + if name.is_empty() { + return Err(Status::invalid_argument( + "extension service names must not be empty", + )); + } + if !unique.insert(name.as_str()) { + return Err(Status::invalid_argument(format!( + "duplicate extension service name '{name}'" + ))); + } + } + + let available: HashMap<&str, &openshell_core::proto::SupervisorMiddlewareService> = + available_services + .iter() + .map(|service| (service.name.as_str(), service)) + .collect(); + let ttl = if issuer.ttl().is_zero() { + DEFAULT_EXTENSION_TOKEN_TTL + } else { + issuer.ttl().min(MAX_EXTENSION_TOKEN_TTL) + }; + + requested_names + .iter() + .map(|name| { + let service = available.get(name.as_str()).ok_or_else(|| { + Status::permission_denied(format!( + "extension service '{name}' is not selected by the sandbox policy" + )) + })?; + let audience = ExtensionAudience::new(service.audience.clone()) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + let minted = issuer.mint_extension_token( + &audience, + ExtensionCallerKind::Supervisor, + Some(sandbox_id), + ttl, + )?; + Ok(ExtensionServiceCredential { + service_name: name.clone(), + token: minted.token, + expires_at_ms: minted.expires_at_ms, + }) + }) + .collect() +} + async fn ensure_sandbox_exists(state: &Arc, sandbox_id: &str) -> Result<(), Status> { if sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); @@ -284,7 +375,9 @@ mod tests { #[tokio::test] async fn refresh_returns_new_token() { let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(sandbox_principal("sandbox-a")); let resp = handle_refresh_sandbox_token(&state, req) .await @@ -294,10 +387,63 @@ mod tests { assert!(resp.expires_at_ms > 0); } + #[tokio::test] + async fn extension_credentials_are_minted_only_for_selected_registration_names() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "content-guard".to_string(), + audience: "urn:example:content-guard".to_string(), + ..Default::default() + }]; + + let credentials = mint_extension_credentials( + issuer, + "sandbox-a", + &["content-guard".to_string()], + &available, + ) + .expect("selected service credential"); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].service_name, "content-guard"); + assert!(!credentials[0].token.is_empty()); + assert!(credentials[0].expires_at_ms > 0); + + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["attacker-chosen-audience".to_string()], + &available, + ) + .expect_err("unselected name must be rejected"); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn extension_credential_request_rejects_duplicate_names_atomically() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "content-guard".to_string(), + audience: "urn:example:content-guard".to_string(), + ..Default::default() + }]; + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["content-guard".to_string(), "content-guard".to_string()], + &available, + ) + .expect_err("duplicates must be rejected"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + #[tokio::test] async fn refresh_rejects_missing_sandbox() { let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut() .insert(sandbox_principal("sandbox-deleted")); let err = handle_refresh_sandbox_token(&state, req) @@ -354,7 +500,9 @@ mod tests { async fn refresh_rejects_user_principal() { use crate::auth::identity::{Identity, IdentityProvider}; let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(Principal::User(UserPrincipal { identity: Identity { subject: "alice".to_string(), @@ -377,7 +525,9 @@ mod tests { // gateway-minted JWT exists. use crate::auth::principal::SandboxIdentitySource; let state = state_with_issuer().await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), @@ -416,7 +566,9 @@ mod tests { None, )); insert_sandbox(&state, "sandbox-a").await; - let mut req = Request::new(RefreshSandboxTokenRequest {}); + let mut req = Request::new(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }); req.extensions_mut().insert(sandbox_principal("sandbox-a")); let err = handle_refresh_sandbox_token(&state, req) .await diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 5cd06d3900..3835ee389d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -60,14 +60,17 @@ mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::{ComputeDriverKind, Config, Error, ObjectLabels, Result}; +use openshell_extension_core::{ + BearerTokenSlot, ExtensionAudience, ExtensionCallerKind, MAX_EXTENSION_TOKEN_TTL, +}; use openshell_supervisor_middleware::MiddlewareRegistry; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::io::ErrorKind; use std::net::SocketAddr; #[cfg(test)] use std::sync::LazyLock; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tracing::{debug, error, info, warn}; @@ -83,6 +86,120 @@ use compute::ComputeRuntime; use gateway_listener::{BoundGatewayListener, GatewayListenerScope, bind_gateway_listeners}; pub use grpc::OpenShellService; pub use http::{health_router, http_router, metrics_router, service_http_router}; + +struct GatewayExtensionCredential { + name: String, + audience: ExtensionAudience, + slot: BearerTokenSlot, + ttl: Duration, +} + +fn extension_token_ttl(issuer: &auth::sandbox_jwt::SandboxJwtIssuer) -> Duration { + if issuer.ttl().is_zero() { + Duration::from_secs(15 * 60) + } else { + issuer.ttl().min(MAX_EXTENSION_TOKEN_TTL) + } +} + +fn mint_gateway_extension_credential( + issuer: &Arc, + name: &str, + audience: &str, + endpoint: &str, +) -> Result { + if !endpoint.starts_with("https://") && !endpoint.starts_with("unix://") { + return Err(Error::config(format!( + "authenticated extension '{name}' must use https:// or unix://" + ))); + } + let audience = ExtensionAudience::new(audience.to_string()).map_err(|error| { + Error::config(format!( + "extension '{name}' has an invalid audience: {error}" + )) + })?; + let ttl = extension_token_ttl(issuer); + let minted = issuer + .mint_extension_token(&audience, ExtensionCallerKind::Gateway, None, ttl) + .map_err(|status| { + Error::config(format!( + "failed to mint credential for extension '{name}': {}", + status.message() + )) + })?; + let slot = BearerTokenSlot::new(&minted.token, minted.expires_at_ms).map_err(|error| { + Error::config(format!( + "failed to install credential for extension '{name}': {error}" + )) + })?; + Ok(GatewayExtensionCredential { + name: name.to_string(), + audience, + slot, + ttl, + }) +} + +fn spawn_gateway_extension_token_refresh( + issuer: Arc, + credentials: Vec, +) { + if credentials.is_empty() { + return; + } + tokio::spawn(async move { + loop { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }); + let remaining_ms = credentials + .iter() + .filter_map(|credential| credential.slot.expires_at_ms()) + .min() + .map_or(60_000, |expiry_ms| expiry_ms.saturating_sub(now_ms)); + let refresh_delay = if remaining_ms <= 0 { + Duration::from_millis(100) + } else { + Duration::from_millis( + u64::try_from(remaining_ms) + .unwrap_or(u64::MAX) + .saturating_mul(4) + .checked_div(5) + .unwrap_or(100) + .max(100), + ) + }; + tokio::time::sleep(refresh_delay).await; + for credential in &credentials { + match issuer.mint_extension_token( + &credential.audience, + ExtensionCallerKind::Gateway, + None, + credential.ttl, + ) { + Ok(minted) => { + if let Err(error) = + credential.slot.update(&minted.token, minted.expires_at_ms) + { + warn!( + extension = %credential.name, + error = %error, + "failed to rotate gateway extension credential" + ); + } + } + Err(status) => warn!( + extension = %credential.name, + error = %status, + "failed to mint gateway extension credential" + ), + } + } + } + }); +} pub use multiplex::{MultiplexService, MultiplexedService}; pub use persistence::Store; use sandbox_index::SandboxIndex; @@ -293,6 +410,60 @@ pub(crate) async fn run_server( return Err(Error::config("database_url is required")); } + // Load signing material before connecting remote extensions so their + // startup Describe calls can authenticate with gateway-caller tokens. + let (sandbox_jwt_issuer, sandbox_jwt_authenticator) = if let Some(ref jwt) = config.gateway_jwt + { + let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT signing key from {}: {e}", + jwt.signing_key_path.display() + )) + })?; + let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT public key from {}: {e}", + jwt.public_key_path.display() + )) + })?; + let kid = std::fs::read_to_string(&jwt.kid_path) + .map_err(|e| { + Error::config(format!( + "failed to read sandbox JWT kid from {}: {e}", + jwt.kid_path.display() + )) + })? + .trim() + .to_string(); + if kid.is_empty() { + return Err(Error::config(format!( + "sandbox JWT kid file {} is empty", + jwt.kid_path.display() + ))); + } + let issuer = Arc::new( + auth::sandbox_jwt::SandboxJwtIssuer::from_pem( + &signing_pem, + kid.clone(), + &jwt.gateway_id, + Duration::from_secs(jwt.ttl_secs), + ) + .map_err(Error::config)?, + ); + let authenticator = Arc::new( + auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(&public_pem, kid, &jwt.gateway_id) + .map_err(Error::config)?, + ); + info!( + gateway_id = %jwt.gateway_id, + ttl_secs = jwt.ttl_secs, + "gateway-minted sandbox JWT enabled" + ); + (Some(issuer), Some(authenticator)) + } else { + (None, None) + }; + let middleware_registrations = config_file .as_ref() .map(|file| { @@ -300,16 +471,39 @@ pub(crate) async fn run_server( .supervisor .middleware .iter() - .map(Into::into) - .collect() + .map(openshell_core::proto::SupervisorMiddlewareService::try_from) + .collect::, _>>() }) + .transpose() + .map_err(|error| Error::config(format!("middleware registration failed: {error}")))? .unwrap_or_default(); + let mut gateway_extension_credentials = Vec::new(); let middleware_registry = Arc::new( - MiddlewareRegistry::connect_services( - openshell_supervisor_middleware_builtins::services(), - middleware_registrations, - ) - .await + if let Some(issuer) = sandbox_jwt_issuer.as_ref() { + let mut slots = HashMap::new(); + for registration in &middleware_registrations { + let credential = mint_gateway_extension_credential( + issuer, + ®istration.name, + ®istration.audience, + ®istration.grpc_endpoint, + )?; + slots.insert(registration.name.clone(), credential.slot.clone()); + gateway_extension_credentials.push(credential); + } + MiddlewareRegistry::connect_services_authenticated( + openshell_supervisor_middleware_builtins::services(), + middleware_registrations, + &slots, + ) + .await + } else { + MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + middleware_registrations, + ) + .await + } .map_err(|error| Error::config(format!("middleware registration failed: {error}")))?, ); @@ -359,12 +553,28 @@ pub(crate) async fn run_server( supervisor_sessions.clone(), ) .await?; - let gateway_interceptors = - openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()) - .await - .map_err(|e| { - Error::config(format!("gateway interceptor initialization failed: {e}")) - })?; + let gateway_interceptors = if let Some(issuer) = sandbox_jwt_issuer.as_ref() { + let mut slots = BTreeMap::new(); + for interceptor in &config.gateway_interceptors { + let audience = interceptor.resolved_audience(); + let credential = mint_gateway_extension_credential( + issuer, + &interceptor.name, + audience.as_ref(), + &interceptor.grpc_endpoint, + )?; + slots.insert(interceptor.name.clone(), credential.slot.clone()); + gateway_extension_credentials.push(credential); + } + openshell_gateway_interceptors::initialize_authenticated( + config.gateway_interceptors.clone(), + slots, + ) + .await + } else { + openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()).await + } + .map_err(|e| Error::config(format!("gateway interceptor initialization failed: {e}")))?; let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( &config.provider_profile_sources, gateway_interceptors.as_ref(), @@ -392,56 +602,10 @@ pub(crate) async fn run_server( state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; - - // Load the gateway-minted sandbox JWT signing key when configured. - // Optional so single-driver dev deployments without certgen continue - // to start. The helm-deployed gateway and the RPM init script populate - // `gateway_jwt` once `certgen` has produced the on-disk material. - if let Some(ref jwt) = config.gateway_jwt { - let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT signing key from {}: {e}", - jwt.signing_key_path.display() - )) - })?; - let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT public key from {}: {e}", - jwt.public_key_path.display() - )) - })?; - let kid = std::fs::read_to_string(&jwt.kid_path) - .map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT kid from {}: {e}", - jwt.kid_path.display() - )) - })? - .trim() - .to_string(); - if kid.is_empty() { - return Err(Error::config(format!( - "sandbox JWT kid file {} is empty", - jwt.kid_path.display() - ))); - } - let issuer = auth::sandbox_jwt::SandboxJwtIssuer::from_pem( - &signing_pem, - kid.clone(), - &jwt.gateway_id, - Duration::from_secs(jwt.ttl_secs), - ) - .map_err(Error::config)?; - let authenticator = - auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(&public_pem, kid, &jwt.gateway_id) - .map_err(Error::config)?; - info!( - gateway_id = %jwt.gateway_id, - ttl_secs = jwt.ttl_secs, - "gateway-minted sandbox JWT enabled" - ); - state.sandbox_jwt_issuer = Some(Arc::new(issuer)); - state.sandbox_jwt_authenticator = Some(Arc::new(authenticator)); + state.sandbox_jwt_issuer = sandbox_jwt_issuer.clone(); + state.sandbox_jwt_authenticator = sandbox_jwt_authenticator; + if let Some(issuer) = sandbox_jwt_issuer { + spawn_gateway_extension_token_refresh(issuer, gateway_extension_credentials); } // K8s ServiceAccount bootstrap authenticator. Only constructed when diff --git a/crates/openshell-supervisor-middleware/Cargo.toml b/crates/openshell-supervisor-middleware/Cargo.toml index 9cdc53febb..052c246930 100644 --- a/crates/openshell-supervisor-middleware/Cargo.toml +++ b/crates/openshell-supervisor-middleware/Cargo.toml @@ -12,6 +12,7 @@ rust-version.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-extension-core = { path = "../openshell-extension-core" } miette = { workspace = true } prost = { workspace = true } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index fe0f15f0a6..cf3b70d536 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -6,9 +6,7 @@ mod headers; mod remote; -#[cfg(test)] -use std::collections::HashMap; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -670,6 +668,25 @@ impl MiddlewareRegistry { pub async fn connect_services( in_process_services: Vec>, registrations: Vec, + ) -> Result { + Self::connect_services_inner(in_process_services, registrations, None).await + } + + /// Connect services with optional refreshable credentials keyed by + /// operator registration name. A configured credential is shared by all + /// generated client clones and can rotate without rebuilding the registry. + pub async fn connect_services_authenticated( + in_process_services: Vec>, + registrations: Vec, + credentials: &HashMap, + ) -> Result { + Self::connect_services_inner(in_process_services, registrations, Some(credentials)).await + } + + async fn connect_services_inner( + in_process_services: Vec>, + registrations: Vec, + credentials: Option<&HashMap>, ) -> Result { let mut services = Vec::with_capacity(in_process_services.len() + registrations.len()); let mut registered_services = Vec::with_capacity(registrations.len()); @@ -737,10 +754,22 @@ impl MiddlewareRegistry { registration.name ) })?; + let bearer = credentials + .map(|credentials| { + credentials.get(®istration.name).cloned().ok_or_else(|| { + miette!( + "middleware registration '{}' is missing its extension credential", + registration.name + ) + }) + }) + .transpose()?; let service = Arc::new( remote::RemoteMiddlewareService::connect( ®istration.name, ®istration.grpc_endpoint, + ®istration.tls_ca_cert_pem, + bearer, ) .await?, ); diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 30ea5a74bb..1a6bf0257e 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::time::Duration; - use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; @@ -10,46 +8,34 @@ use openshell_core::proto::{ HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, }; -use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use openshell_extension_core::{ + BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, connect_channel, +}; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; use tonic::{Request, Response, Status}; use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); -const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20); +type ExtensionChannel = InterceptedService; #[derive(Clone)] pub struct RemoteMiddlewareService { - client: SupervisorMiddlewareClient, + client: SupervisorMiddlewareClient, } impl RemoteMiddlewareService { - pub async fn connect(registration_name: &str, grpc_endpoint: &str) -> Result { - let mut endpoint = Endpoint::from_shared(grpc_endpoint.to_string()) - .into_diagnostic() - .wrap_err_with(|| { - format!( - "middleware registration '{registration_name}' has an invalid grpc_endpoint" - ) - })? - .http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL) - .keep_alive_while_idle(true) - .keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT) - .http2_adaptive_window(true); - - if grpc_endpoint.starts_with("https://") { - endpoint = endpoint - .tls_config(ClientTlsConfig::new().with_enabled_roots()) - .into_diagnostic() - .wrap_err_with(|| { - format!("middleware registration '{registration_name}' could not configure TLS") - })?; + pub async fn connect( + registration_name: &str, + grpc_endpoint: &str, + tls_ca_cert_pem: &[u8], + bearer: Option, + ) -> Result { + let mut config = ExtensionChannelConfig::new(grpc_endpoint); + if !tls_ca_cert_pem.is_empty() { + config = config.with_custom_ca_pem(tls_ca_cert_pem); } - - let channel = endpoint - .connect_timeout(CONNECT_TIMEOUT) - .connect() + let channel = connect_channel(&config) .await .into_diagnostic() .wrap_err_with(|| { @@ -57,6 +43,9 @@ impl RemoteMiddlewareService { "middleware registration '{registration_name}' could not connect to {grpc_endpoint}" ) })?; + let interceptor = + bearer.map_or_else(BearerTokenInterceptor::disabled, |slot| slot.interceptor()); + let channel = InterceptedService::new(channel, interceptor); Ok(Self { client: SupervisorMiddlewareClient::new(channel) diff --git a/crates/openshell-supervisor-process/src/debug_rpc.rs b/crates/openshell-supervisor-process/src/debug_rpc.rs index f583d54dcd..6f885a69db 100644 --- a/crates/openshell-supervisor-process/src/debug_rpc.rs +++ b/crates/openshell-supervisor-process/src/debug_rpc.rs @@ -105,7 +105,9 @@ async fn run_get_sandbox_config(args: &[String]) -> Result { async fn run_refresh() -> Result { let mut client = open_client().await?; let resp = client - .refresh_sandbox_token(RefreshSandboxTokenRequest {}) + .refresh_sandbox_token(RefreshSandboxTokenRequest { + extension_service_names: Vec::new(), + }) .await; match resp { Ok(r) => { diff --git a/docs/extensibility/gateway-interceptors.mdx b/docs/extensibility/gateway-interceptors.mdx index 9fe50ef5a7..b1f24f3c26 100644 --- a/docs/extensibility/gateway-interceptors.mdx +++ b/docs/extensibility/gateway-interceptors.mdx @@ -73,7 +73,9 @@ Start the interceptor before the gateway, then register it in gateway TOML: ```toml [[openshell.gateway.interceptors]] name = "policy-governance" -grpc_endpoint = "http://127.0.0.1:18081" +grpc_endpoint = "https://governance.example:18081" +tls_ca_cert_path = "/etc/openshell/governance-ca.pem" +audience = "urn:example:governance" order = 10 failure_policy = "fail_closed" binding_policy = "allowlist" @@ -90,7 +92,9 @@ rpc = "openshell.v1.OpenShell/UpdateConfig" phases = ["validate"] ``` -The gateway supports `http://`, `https://`, and `unix://` interceptor endpoints. It calls `Describe` and builds an immutable execution plan during startup. An unavailable service, invalid manifest, or unauthorized configured binding prevents the gateway from starting. +The gateway supports `http://`, `https://`, and `unix://` interceptor endpoints. When gateway JWT signing is configured, authenticated network interceptors use `https://`; Unix sockets remain available for local integrations. HTTPS uses platform trust roots unless `tls_ca_cert_path` supplies a private CA, and normal hostname verification remains enabled. The gateway calls `Describe` and builds an immutable execution plan during startup. An unavailable service, invalid manifest, missing credential, or unauthorized configured binding prevents the gateway from starting. + +The gateway attaches a short-lived EdDSA bearer token to `Describe`, `Evaluate`, and provider-profile snapshot calls. The token uses the configured `audience` (defaulting to `urn:openshell:extension:interceptor:`) and `caller_kind: gateway`. Provision the trusted gateway URL and expected gateway ID separately from token data. The expected issuer is exactly `openshell-gateway:`; JWKS does not establish that identity by itself. Fetch the public signing key from `GET /.well-known/jwks.json` over authenticated TLS at that URL, or provision it through the deployment when the interceptor cannot reach the gateway. Pin `alg` to `EdDSA` and validate `kid`, signature, expected issuer, exact audience, positive expiry, and caller kind. Registration is static. Restart the gateway after adding, removing, or changing an interceptor. See [Gateway Configuration](/reference/gateway-config#gateway-interceptors) for the complete field reference. @@ -163,5 +167,5 @@ The gateway emits structured evaluation logs containing the interceptor name, bi - Only explicitly allowlisted unary write RPCs are interceptable. New gateway RPCs are non-interceptable until added to the allowlist. - `current_state` is available only in the `validate` contract. The gateway does not yet populate it with method-specific state. - Registration changes require a gateway restart. -- Custom TLS roots, client authentication, service health checks, and runtime registration are not available. +- mTLS client authentication, service health checks, runtime registration, and overlapping signing-key rotation are not available. - Interceptors cannot receive or mutate protobuf fields marked secret. diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index f3bdcac9bb..29b10cd202 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -46,7 +46,9 @@ Start an operator-run service before starting the gateway, then add a registrati ```toml [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" +grpc_endpoint = "https://content-guard.example:50051" +tls_ca_cert_path = "/etc/openshell/content-guard-ca.pem" +audience = "urn:example:content-guard" max_body_bytes = 262144 timeout = "500ms" ``` @@ -54,7 +56,9 @@ timeout = "500ms" | Field | Description | | --- | --- | | `name` | Operator-owned registration name used by policy attachments and diagnostics. Names must be unique, and `openshell/` is reserved for built-ins. | -| `grpc_endpoint` | Service address reachable from both the gateway and sandbox supervisors. Supports plaintext `http://` and TLS `https://` with platform trust roots. | +| `grpc_endpoint` | Service address reachable from both the gateway and sandbox supervisors. Authenticated extensions use TLS `https://`. | +| `tls_ca_cert_path` | Optional PEM trust roots for a private HTTPS service. Custom roots replace platform roots and retain hostname verification. | +| `audience` | Exact audience expected by the service. Defaults to `urn:openshell:extension:middleware:`. | | `max_body_bytes` | Operator limit applied to every binding exposed by the service, up to the 4 MiB platform maximum. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | @@ -64,6 +68,12 @@ The gateway connects to every registered service and verifies its capabilities b Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. +### Authenticate OpenShell Callers + +When gateway JWT signing is configured, OpenShell attaches a short-lived EdDSA bearer token to every remote middleware RPC. Gateway calls use `caller_kind: gateway`; sandbox supervisor calls use `caller_kind: supervisor` and include the sandbox ID. Supervisors request credentials by registration name through `RefreshSandboxToken`. The gateway derives the audience from operator-owned configuration and authorizes each name against the sandbox's effective policy. + +Provision the trusted gateway URL and expected gateway ID separately from token data. The expected issuer is exactly `openshell-gateway:`; fetching JWKS does not establish that identity by itself. Obtain the public key from `GET /.well-known/jwks.json` over authenticated TLS at the trusted gateway URL, or provision it through the deployment when the service cannot reach the gateway. Cache keys by `kid`. Pin `alg` to `EdDSA` and validate the signature, expected issuer, exact audience, positive expiry, caller kind, and sandbox identity when required. A sandbox-to-gateway JWT is not an extension credential even though both token types use the same signing key. + ## Apply Middleware with Policy Add middleware configs to the top-level `network_middlewares` map. Each key is the policy-local config name: @@ -175,5 +185,5 @@ See [Logging](/observability/logging) for log access and [OCSF JSON Export](/obs - The typed operation and phase are `HTTP_REQUEST/PRE_CREDENTIALS`. - Selection uses destination host include and exclude patterns. - A fail-closed middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. An all-`fail_open` match may cover the endpoint; OpenShell bypasses the middleware and emits a detection finding. -- Operator-run services support plaintext `http://` and TLS `https://` endpoints. HTTPS certificates must chain to a CA in the platform trust store. -- Custom trust roots, client authentication, health checks, and runtime registration are not available. +- Operator-run services use TLS `https://` when gateway JWT signing is enabled. Certificates must chain to the configured custom CA or platform roots, and the endpoint hostname must match. +- mTLS client authentication, health checks, runtime registration, and overlapping signing-key rotation are not available. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 2cd10b8a0b..7af061177a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -130,7 +130,9 @@ provider_profile_sources = [ # both the gateway and sandbox supervisors. [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" +grpc_endpoint = "https://host.openshell.internal:50051" +tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem" +audience = "urn:openshell:middleware:local-content-guard" max_body_bytes = 262144 timeout = "500ms" @@ -172,6 +174,7 @@ scopes_claim = "" [[openshell.gateway.interceptors]] name = "quota" grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" +audience = "urn:openshell:interceptor:quota" order = 10 failure_policy = "fail_closed" binding_policy = "allowlist" @@ -255,7 +258,9 @@ Register operator-run supervisor middleware services with one or more `[[openshe ```toml [[openshell.supervisor.middleware]] name = "local-content-guard" -grpc_endpoint = "http://host.openshell.internal:50051" +grpc_endpoint = "https://host.openshell.internal:50051" +tls_ca_cert_path = "/etc/openshell/certs/content-guard-ca.pem" +audience = "urn:openshell:middleware:local-content-guard" max_body_bytes = 262144 timeout = "500ms" ``` @@ -268,7 +273,7 @@ The gateway connects to every registered service and validates `Describe` before `timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The effective timeout covers `ValidateConfig` and `EvaluateHttpRequest`; `Describe` uses the service timeout because binding metadata is not available yet. -The service `grpc_endpoint` currently supports plaintext `http://` and TLS `https://` using the platform trust store. Custom trust roots, client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. +The service `grpc_endpoint` supports plaintext `http://` on legacy gateways without JWT signing and TLS `https://` for authenticated extensions. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. When `gateway_jwt` is configured, OpenShell requires HTTPS and attaches short-lived bearer credentials to gateway and supervisor calls. mTLS client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, failure, body-limit, and operational guidance. @@ -276,6 +281,8 @@ See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, `[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target only methods in the gateway's built-in allowlist of unary mutation RPCs. New RPCs are non-interceptable until they are deliberately added to that allowlist; adding one does not require handler-specific interceptor code. Request bodies are exposed as protobuf JSON objects. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object. +HTTPS interceptor endpoints use the platform trust store by default. Set `tls_ca_cert_path` to a PEM certificate bundle for a private CA; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:interceptor:`. When `gateway_jwt` is configured, network interceptors must use HTTPS and receive short-lived gateway-caller bearer credentials; local Unix sockets are also supported. + `binding_policy` controls how the manifest and operator binding configuration combine: - `dynamic` enables valid manifest bindings and treats configured entries as optional narrowing overrides. This is the compatibility default. The gateway logs a startup warning because the interceptor controls its non-secret RPC authority. diff --git a/proto/openshell.proto b/proto/openshell.proto index 9f2fdf9006..df35b6dcae 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -664,10 +664,16 @@ message IssueSandboxTokenResponse { int64 expires_at_ms = 2; } -// RefreshSandboxToken request. Empty body; the calling principal must -// already be a sandbox principal (i.e. the request carries a still-valid -// gateway-minted JWT in its Authorization header). -message RefreshSandboxTokenRequest {} +// RefreshSandboxToken request. The calling principal must already be a +// sandbox principal (i.e. the request carries a still-valid gateway-minted +// JWT in its Authorization header). Extension service names are resolved +// against server-owned registrations and the sandbox's effective policy; +// callers never choose token audiences directly. +message RefreshSandboxTokenRequest { + // Operator registration names for extension services selected by the + // sandbox's effective policy. + repeated string extension_service_names = 1; +} // RefreshSandboxToken response. The new token replaces the supervisor's // in-memory bearer credential. @@ -677,8 +683,12 @@ message RefreshSandboxTokenResponse { // Absolute expiry of the new token, milliseconds since the epoch. 0 means // the token is non-expiring. int64 expires_at_ms = 2; + // Fresh credentials for the requested, policy-authorized extension + // services. These remain in supervisor memory and are never persisted. + repeated ExtensionServiceCredential extension_credentials = 3; } + // Health check request. message HealthRequest {} @@ -2598,3 +2608,16 @@ message ListWorkspaceMembersRequest { message ListWorkspaceMembersResponse { repeated WorkspaceMember members = 1; } + +// Short-lived credential for one policy-authorized extension service. +// Kept at the end of the file so adding it does not renumber existing +// generated message descriptors. +message ExtensionServiceCredential { + // Operator registration name used to correlate the credential with the + // stable service registration delivered by GetSandboxConfig. + string service_name = 1; + // Gateway-minted JWT with an audience derived from the registration. + string token = 2 [(openshell.options.v1.secret) = true]; + // Absolute expiry of the token, milliseconds since the epoch. + int64 expires_at_ms = 3; +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 9ccefadefb..4edd8186df 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -386,4 +386,11 @@ message SupervisorMiddlewareService { // 500ms. Values use an integer with an `ms` or `s` suffix and must be // between 10ms and 30s. string timeout = 4; + // PEM-encoded trust roots loaded by the gateway from the operator-configured + // tls_ca_cert_path. Empty uses the platform trust store. + bytes tls_ca_cert_pem = 5; + // Exact JWT audience for this service. The gateway resolves an omitted + // operator value to a kind-scoped audience derived from the registration + // name before sending sandbox config. + string audience = 6; } diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index f43e05ed79..f812d0e478 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -328,13 +328,13 @@ grpc_endpoint = "https://middleware.example.internal:443" max_body_bytes = 1048576 ``` -The stable transport requirement is confidentiality plus authentication of the intended middleware service. Phase 1 may temporarily accept a plaintext `http://` endpoint only when the same entry explicitly sets `allow_insecure = true`. OpenShell rejects plaintext without that opt-in, warns prominently, and records the insecure registration as auditable configuration state. This escape hatch is limited to trusted local development and isolated research environments. Phase 2 removes plaintext support and the `allow_insecure` field, requiring authenticated encrypted transport. That removal is an intentional research-preview breaking change with no long-term compatibility obligation. The exact phase 2 mechanism, such as mTLS or TLS plus explicit caller authentication, is follow-up protocol work (see [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication)). +The stable transport requirement is confidentiality plus authentication of the intended middleware service. The alpha mechanism uses HTTPS with platform roots or an operator-provided CA and retains normal hostname verification. OpenShell authenticates gateway and supervisor calls with short-lived, exact-audience Ed25519 JWTs. Supervisors obtain only policy-selected service credentials through `RefreshSandboxToken`; services verify the public key through gateway JWKS or operator provisioning. mTLS and overlapping signing-key rotation remain follow-up hardening (see [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication)). For each binding, the operator's `max_body_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. The resulting operator limit applies to every binding exposed by that registration. RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise its own timeout through `Describe`; that value overrides the service registration timeout. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig` and `EvaluateHttpRequest`. -The external-service endpoint is trusted operator infrastructure in v1. The auth design must make both directions explicit: the supervisor proves to the middleware that the call is authorized for the specific middleware identity, and the supervisor verifies it is calling the intended middleware service. +The external-service endpoint is trusted operator infrastructure in v1. Both directions are explicit: TLS and the configured trust roots authenticate the middleware service, while the exact-audience JWT proves that a gateway or policy-authorized sandbox supervisor made the call. The middleware validates issuer, audience, expiry, caller kind, and sandbox identity where applicable. Binding IDs may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex` or `openshell/sigv4`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the binding. @@ -515,7 +515,7 @@ This section closes the current review themes. ### Explicit deferrals - **Provider-profile middleware.** V1 middleware configs live in sandbox policy, not provider profiles. Provider-supplied network policies can be targeted after effective policy assembly. Provider-profile opt-ins for built-in middleware such as `openshell/sigv4`, and reusable cross-sandbox middleware profiles, are follow-up design work. -- **Authenticated transport mechanism.** Phase 2 requires authenticated encrypted transport. The exact choice between mTLS, TLS plus caller authentication, or an equivalent mechanism, including credential delivery and rotation, is follow-up protocol work. +- **Authenticated transport hardening.** Alpha uses custom-CA-capable TLS plus gateway-signed bearer JWTs and single-key JWKS. mTLS, replay resistance beyond short expiry, and overlapping key rotation remain follow-up work. - **Health checks.** V1 relies on connection establishment, `Describe`, per-request invocation, timeout, `on_error`, and registry polling. A dedicated health RPC can improve alerting later but is not required for correctness. - **Registration ergonomics and ownership.** V1 middleware registration is an operator concern: middleware services are declared in gateway configuration and changing the registered set requires a gateway restart. Runtime user-managed registration, CLI/API helpers, SDK helpers, and an agent skill for scaffolding or registering middleware are useful follow-ups after the policy and service contract stabilize. - **Post-call budget reconciliation.** Budget-style middleware that needs final route/model, status, content length, or token usage needs a metadata-only hook such as `HttpResponse/completed`. That hook is listed as a future extension and is not part of the v1 request hook. diff --git a/rfc/0010-gateway-interceptors/README.md b/rfc/0010-gateway-interceptors/README.md index b49d39eadf..946150cb29 100644 --- a/rfc/0010-gateway-interceptors/README.md +++ b/rfc/0010-gateway-interceptors/README.md @@ -273,9 +273,12 @@ The framework uses one protobuf/gRPC service contract. Gateway interceptor endpoints connect over gRPC, either to a remote endpoint or over a Unix domain socket. -All gateway interceptor connections require authentication. The exact -authentication model is out of scope for this RFC, but implementations should -support mTLS and bearer-token authentication. +Gateway interceptor connections use short-lived, exact-audience bearer JWTs +minted by the gateway's existing Ed25519 signing authority. Integrations verify +the gateway key through its JWKS or an operator-provisioned public key and +validate issuer, audience, expiry, and `caller_kind: gateway`. HTTPS supports +an operator-provided CA with hostname verification. mTLS and overlapping key +rotation remain deferred hardening. ### Selection and ordering diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 2696be0e0a..5ba3c5e16e 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -476,13 +476,18 @@ func (x *IssueSandboxTokenResponse) GetExpiresAtMs() int64 { return 0 } -// RefreshSandboxToken request. Empty body; the calling principal must -// already be a sandbox principal (i.e. the request carries a still-valid -// gateway-minted JWT in its Authorization header). +// RefreshSandboxToken request. The calling principal must already be a +// sandbox principal (i.e. the request carries a still-valid gateway-minted +// JWT in its Authorization header). Extension service names are resolved +// against server-owned registrations and the sandbox's effective policy; +// callers never choose token audiences directly. type RefreshSandboxTokenRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // Operator registration names for extension services selected by the + // sandbox's effective policy. + ExtensionServiceNames []string `protobuf:"bytes,1,rep,name=extension_service_names,json=extensionServiceNames,proto3" json:"extension_service_names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RefreshSandboxTokenRequest) Reset() { @@ -515,6 +520,13 @@ func (*RefreshSandboxTokenRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{2} } +func (x *RefreshSandboxTokenRequest) GetExtensionServiceNames() []string { + if x != nil { + return x.ExtensionServiceNames + } + return nil +} + // RefreshSandboxToken response. The new token replaces the supervisor's // in-memory bearer credential. type RefreshSandboxTokenResponse struct { @@ -523,9 +535,12 @@ type RefreshSandboxTokenResponse struct { Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` // Absolute expiry of the new token, milliseconds since the epoch. 0 means // the token is non-expiring. - ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Fresh credentials for the requested, policy-authorized extension + // services. These remain in supervisor memory and are never persisted. + ExtensionCredentials []*ExtensionServiceCredential `protobuf:"bytes,3,rep,name=extension_credentials,json=extensionCredentials,proto3" json:"extension_credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RefreshSandboxTokenResponse) Reset() { @@ -572,6 +587,13 @@ func (x *RefreshSandboxTokenResponse) GetExpiresAtMs() int64 { return 0 } +func (x *RefreshSandboxTokenResponse) GetExtensionCredentials() []*ExtensionServiceCredential { + if x != nil { + return x.ExtensionCredentials + } + return nil +} + // Health check request. type HealthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -12711,6 +12733,73 @@ func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { return nil } +// Short-lived credential for one policy-authorized extension service. +// Kept at the end of the file so adding it does not renumber existing +// generated message descriptors. +type ExtensionServiceCredential struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Operator registration name used to correlate the credential with the + // stable service registration delivered by GetSandboxConfig. + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + // Gateway-minted JWT with an audience derived from the registration. + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the token, milliseconds since the epoch. + ExpiresAtMs int64 `protobuf:"varint,3,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExtensionServiceCredential) Reset() { + *x = ExtensionServiceCredential{} + mi := &file_openshell_proto_msgTypes[180] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExtensionServiceCredential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExtensionServiceCredential) ProtoMessage() {} + +func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[180] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. +func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{180} +} + +func (x *ExtensionServiceCredential) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *ExtensionServiceCredential) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ExtensionServiceCredential) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + @@ -12719,11 +12808,13 @@ const file_openshell_proto_rawDesc = "" + "\x18IssueSandboxTokenRequest\"[\n" + "\x19IssueSandboxTokenResponse\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x1c\n" + - "\x1aRefreshSandboxTokenRequest\"]\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"T\n" + + "\x1aRefreshSandboxTokenRequest\x126\n" + + "\x17extension_service_names\x18\x01 \x03(\tR\x15extensionServiceNames\"\xbc\x01\n" + "\x1bRefreshSandboxTokenResponse\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x0f\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\x12]\n" + + "\x15extension_credentials\x18\x03 \x03(\v2(.openshell.v1.ExtensionServiceCredentialR\x14extensionCredentials\"\x0f\n" + "\rHealthRequest\"_\n" + "\x0eHealthResponse\x123\n" + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12\x18\n" + @@ -13683,7 +13774,11 @@ const file_openshell_proto_rawDesc = "" + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + "\x06offset\x18\x03 \x01(\rR\x06offset\"W\n" + "\x1cListWorkspaceMembersResponse\x127\n" + - "\amembers\x18\x01 \x03(\v2\x1d.openshell.v1.WorkspaceMemberR\amembers*\xb6\x01\n" + + "\amembers\x18\x01 \x03(\v2\x1d.openshell.v1.WorkspaceMemberR\amembers\"\x7f\n" + + "\x1aExtensionServiceCredential\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1a\n" + + "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs*\xb6\x01\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -13869,7 +13964,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 6) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 203) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 204) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialRefreshStrategy)(0), // 1: openshell.v1.ProviderCredentialRefreshStrategy @@ -14057,326 +14152,328 @@ var file_openshell_proto_goTypes = []any{ (*RemoveWorkspaceMemberResponse)(nil), // 183: openshell.v1.RemoveWorkspaceMemberResponse (*ListWorkspaceMembersRequest)(nil), // 184: openshell.v1.ListWorkspaceMembersRequest (*ListWorkspaceMembersResponse)(nil), // 185: openshell.v1.ListWorkspaceMembersResponse - nil, // 186: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 187: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 188: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 189: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 190: openshell.v1.PlatformEvent.MetadataEntry - nil, // 191: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 192: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 193: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 194: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 195: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 196: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 197: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 198: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 199: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 200: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 201: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 203: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 204: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 205: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 206: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 207: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 208: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 209: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 210: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 211: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 212: openshell.datamodel.v1.Provider - (*sandboxv1.NetworkEndpoint)(nil), // 213: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 214: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 215: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 216: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 217: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 218: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 219: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 220: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 221: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 222: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 223: openshell.sandbox.v1.GetGatewayConfigResponse + (*ExtensionServiceCredential)(nil), // 186: openshell.v1.ExtensionServiceCredential + nil, // 187: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 188: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 189: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 190: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 191: openshell.v1.PlatformEvent.MetadataEntry + nil, // 192: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 193: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 194: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 195: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 196: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 197: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 198: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 199: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 200: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 201: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 203: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 204: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 205: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 206: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 207: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 208: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 209: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 210: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 211: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 212: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 213: openshell.datamodel.v1.Provider + (*sandboxv1.NetworkEndpoint)(nil), // 214: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 215: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 216: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 217: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 218: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 219: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 220: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 221: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 222: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 223: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 224: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 4, // 0: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 4, // 1: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 16, // 2: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 17, // 3: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 209, // 4: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 19, // 5: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 23, // 6: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 186, // 7: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 22, // 8: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 210, // 9: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 20, // 10: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 21, // 11: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 187, // 12: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 188, // 13: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 189, // 14: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 211, // 15: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 211, // 16: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 24, // 17: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition - 0, // 18: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 190, // 19: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 19, // 20: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 191, // 21: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 192, // 22: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 18, // 23: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 18, // 24: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 212, // 25: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 18, // 26: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 18, // 27: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 48, // 28: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 209, // 29: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 47, // 30: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 193, // 31: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 52, // 32: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 53, // 33: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 54, // 34: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 136, // 35: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 137, // 36: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 56, // 37: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 51, // 38: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 59, // 39: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 209, // 40: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 18, // 41: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 63, // 42: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 25, // 43: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 64, // 44: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 147, // 45: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 194, // 46: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 212, // 47: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 212, // 48: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 195, // 49: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 212, // 50: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 212, // 51: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 93, // 52: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 76, // 53: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 81, // 54: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 77, // 55: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 1, // 56: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 79, // 57: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 80, // 58: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 1, // 59: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 209, // 60: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 1, // 61: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 196, // 62: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 197, // 63: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 82, // 64: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 1, // 65: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 198, // 66: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 82, // 67: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 82, // 68: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 69: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 78, // 70: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 213, // 71: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 214, // 72: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 83, // 73: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 199, // 74: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 209, // 75: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 93, // 76: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 93, // 77: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 93, // 78: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 74, // 79: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 75, // 80: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 93, // 81: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 74, // 82: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 75, // 83: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 93, // 84: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 74, // 85: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 75, // 86: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 200, // 87: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 201, // 88: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 202, // 89: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 210, // 90: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 215, // 91: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 109, // 92: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 203, // 93: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 110, // 94: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 111, // 95: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 112, // 96: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 113, // 97: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 114, // 98: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 115, // 99: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 216, // 100: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 217, // 101: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 218, // 102: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 204, // 103: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 123, // 104: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 123, // 105: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 3, // 106: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 3, // 107: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 210, // 108: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 205, // 109: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 63, // 110: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 63, // 111: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 130, // 112: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 133, // 113: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 140, // 114: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 141, // 115: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 131, // 116: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 132, // 117: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 134, // 118: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 135, // 119: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 141, // 120: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 136, // 121: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 137, // 122: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 138, // 123: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 142, // 124: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 144, // 125: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 216, // 126: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 143, // 127: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 146, // 128: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 145, // 129: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 146, // 130: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 216, // 131: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 165, // 132: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 210, // 133: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 206, // 134: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 216, // 135: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 207, // 136: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 208, // 137: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 219, // 138: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 219, // 139: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 219, // 140: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 209, // 141: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 5, // 142: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 5, // 143: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 179, // 144: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 179, // 145: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 78, // 146: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 10, // 147: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 12, // 148: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 14, // 149: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 26, // 150: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 27, // 151: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 28, // 152: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 29, // 153: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 30, // 154: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 31, // 155: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 32, // 156: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 39, // 157: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 41, // 158: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 42, // 159: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 43, // 160: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 45, // 161: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 49, // 162: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 51, // 163: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 57, // 164: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 58, // 165: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 65, // 166: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 66, // 167: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 67, // 168: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 72, // 169: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 73, // 170: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 97, // 171: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 99, // 172: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 101, // 173: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 68, // 174: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 85, // 175: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 87, // 176: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 89, // 177: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 91, // 178: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 69, // 179: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 104, // 180: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 220, // 181: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 221, // 182: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 108, // 183: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 117, // 184: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 119, // 185: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 121, // 186: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 106, // 187: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 124, // 188: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 125, // 189: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 128, // 190: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 139, // 191: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 61, // 192: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 148, // 193: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 150, // 194: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 152, // 195: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 154, // 196: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 156, // 197: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 158, // 198: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 160, // 199: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 162, // 200: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 164, // 201: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 6, // 202: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 8, // 203: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 171, // 204: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 173, // 205: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 175, // 206: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 177, // 207: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 180, // 208: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 182, // 209: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 184, // 210: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 11, // 211: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 13, // 212: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 15, // 213: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 33, // 214: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 33, // 215: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 34, // 216: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 35, // 217: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 36, // 218: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 37, // 219: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 38, // 220: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 40, // 221: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 48, // 222: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 48, // 223: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 44, // 224: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 46, // 225: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 50, // 226: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 55, // 227: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 57, // 228: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 55, // 229: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 70, // 230: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 70, // 231: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 71, // 232: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 96, // 233: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 95, // 234: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 98, // 235: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 100, // 236: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 102, // 237: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 70, // 238: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 86, // 239: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 88, // 240: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 90, // 241: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 92, // 242: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 103, // 243: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 105, // 244: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 222, // 245: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 223, // 246: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 116, // 247: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 118, // 248: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 120, // 249: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 122, // 250: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 107, // 251: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 127, // 252: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 126, // 253: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 129, // 254: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 139, // 255: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 62, // 256: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 149, // 257: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 151, // 258: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 153, // 259: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 155, // 260: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 157, // 261: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 159, // 262: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 161, // 263: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 163, // 264: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 166, // 265: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 7, // 266: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 9, // 267: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 172, // 268: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 174, // 269: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 176, // 270: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 178, // 271: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 181, // 272: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 183, // 273: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 185, // 274: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 211, // [211:275] is the sub-list for method output_type - 147, // [147:211] is the sub-list for method input_type - 147, // [147:147] is the sub-list for extension type_name - 147, // [147:147] is the sub-list for extension extendee - 0, // [0:147] is the sub-list for field type_name + 186, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 4, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 4, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 16, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 17, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 210, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 19, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 23, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 187, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 22, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 211, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 20, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 21, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 188, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 189, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 190, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 212, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 212, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 24, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 191, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 19, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 192, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 193, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 18, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 18, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 213, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 18, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 18, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 48, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 210, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 47, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 194, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 52, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 53, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 54, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 136, // 36: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 137, // 37: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 56, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 51, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 59, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 210, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 18, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 63, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 25, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 64, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 147, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 195, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 213, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 213, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 196, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 213, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 213, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 93, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 76, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 81, // 55: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 77, // 56: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 1, // 57: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 79, // 58: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 80, // 59: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 1, // 60: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 210, // 61: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 1, // 62: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 197, // 63: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 198, // 64: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 82, // 65: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 1, // 66: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 199, // 67: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 82, // 68: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 82, // 69: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 70: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 78, // 71: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 214, // 72: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 215, // 73: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 83, // 74: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 200, // 75: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 210, // 76: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 93, // 77: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 93, // 78: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 93, // 79: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 74, // 80: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 75, // 81: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 93, // 82: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 74, // 83: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 75, // 84: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 93, // 85: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 74, // 86: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 75, // 87: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 201, // 88: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 202, // 89: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 203, // 90: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 211, // 91: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 216, // 92: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 109, // 93: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 204, // 94: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 110, // 95: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 111, // 96: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 112, // 97: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 113, // 98: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 114, // 99: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 115, // 100: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 217, // 101: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 218, // 102: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 219, // 103: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 205, // 104: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 123, // 105: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 123, // 106: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 3, // 107: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 3, // 108: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 211, // 109: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 206, // 110: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 63, // 111: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 63, // 112: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 130, // 113: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 133, // 114: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 140, // 115: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 141, // 116: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 131, // 117: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 132, // 118: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 134, // 119: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 135, // 120: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 141, // 121: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 136, // 122: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 137, // 123: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 138, // 124: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 142, // 125: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 144, // 126: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 217, // 127: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 143, // 128: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 146, // 129: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 145, // 130: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 146, // 131: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 217, // 132: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 165, // 133: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 211, // 134: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 207, // 135: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 217, // 136: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 208, // 137: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 209, // 138: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 220, // 139: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 220, // 140: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 220, // 141: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 210, // 142: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 5, // 143: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 5, // 144: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 179, // 145: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 179, // 146: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 78, // 147: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 10, // 148: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 12, // 149: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 14, // 150: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 26, // 151: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 27, // 152: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 28, // 153: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 29, // 154: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 30, // 155: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 31, // 156: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 32, // 157: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 39, // 158: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 41, // 159: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 42, // 160: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 43, // 161: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 45, // 162: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 49, // 163: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 51, // 164: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 57, // 165: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 58, // 166: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 65, // 167: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 66, // 168: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 67, // 169: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 72, // 170: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 73, // 171: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 97, // 172: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 99, // 173: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 101, // 174: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 68, // 175: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 85, // 176: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 87, // 177: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 89, // 178: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 91, // 179: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 69, // 180: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 104, // 181: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 221, // 182: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 222, // 183: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 108, // 184: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 117, // 185: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 119, // 186: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 121, // 187: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 106, // 188: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 124, // 189: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 125, // 190: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 128, // 191: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 139, // 192: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 61, // 193: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 148, // 194: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 150, // 195: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 152, // 196: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 154, // 197: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 156, // 198: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 158, // 199: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 160, // 200: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 162, // 201: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 164, // 202: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 6, // 203: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 8, // 204: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 171, // 205: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 173, // 206: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 175, // 207: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 177, // 208: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 180, // 209: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 182, // 210: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 184, // 211: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 11, // 212: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 13, // 213: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 15, // 214: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 33, // 215: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 33, // 216: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 34, // 217: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 35, // 218: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 36, // 219: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 37, // 220: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 38, // 221: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 40, // 222: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 48, // 223: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 48, // 224: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 44, // 225: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 46, // 226: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 50, // 227: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 55, // 228: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 57, // 229: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 55, // 230: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 70, // 231: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 70, // 232: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 71, // 233: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 96, // 234: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 95, // 235: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 98, // 236: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 100, // 237: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 102, // 238: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 70, // 239: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 86, // 240: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 88, // 241: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 90, // 242: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 92, // 243: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 103, // 244: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 105, // 245: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 223, // 246: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 224, // 247: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 116, // 248: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 118, // 249: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 120, // 250: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 122, // 251: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 107, // 252: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 127, // 253: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 126, // 254: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 129, // 255: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 139, // 256: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 62, // 257: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 149, // 258: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 151, // 259: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 153, // 260: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 155, // 261: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 157, // 262: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 159, // 263: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 161, // 264: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 163, // 265: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 166, // 266: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 7, // 267: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 9, // 268: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 172, // 269: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 174, // 270: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 176, // 271: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 178, // 272: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 181, // 273: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 183, // 274: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 185, // 275: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 212, // [212:276] is the sub-list for method output_type + 148, // [148:212] is the sub-list for method input_type + 148, // [148:148] is the sub-list for extension type_name + 148, // [148:148] is the sub-list for extension extendee + 0, // [0:148] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -14449,7 +14546,7 @@ func file_openshell_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 6, - NumMessages: 203, + NumMessages: 204, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 6ed4cf2ec0..05f54b0bfc 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -1866,7 +1866,14 @@ type SupervisorMiddlewareService struct { // Default RPC timeout for this service. Empty uses the platform default of // 500ms. Values use an integer with an `ms` or `s` suffix and must be // between 10ms and 30s. - Timeout string `protobuf:"bytes,4,opt,name=timeout,proto3" json:"timeout,omitempty"` + Timeout string `protobuf:"bytes,4,opt,name=timeout,proto3" json:"timeout,omitempty"` + // PEM-encoded trust roots loaded by the gateway from the operator-configured + // tls_ca_cert_path. Empty uses the platform trust store. + TlsCaCertPem []byte `protobuf:"bytes,5,opt,name=tls_ca_cert_pem,json=tlsCaCertPem,proto3" json:"tls_ca_cert_pem,omitempty"` + // Exact JWT audience for this service. The gateway resolves an omitted + // operator value to a kind-scoped audience derived from the registration + // name before sending sandbox config. + Audience string `protobuf:"bytes,6,opt,name=audience,proto3" json:"audience,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1929,6 +1936,20 @@ func (x *SupervisorMiddlewareService) GetTimeout() string { return "" } +func (x *SupervisorMiddlewareService) GetTlsCaCertPem() []byte { + if x != nil { + return x.TlsCaCertPem + } + return nil +} + +func (x *SupervisorMiddlewareService) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + @@ -2094,12 +2115,14 @@ const file_sandbox_proto_rawDesc = "" + "\x1epolicy_validation_failure_mode\x18\v \x01(\tR\x1bpolicyValidationFailureMode\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + - "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\x96\x01\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xd9\x01\n" + "\x1bSupervisorMiddlewareService\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + "\rgrpc_endpoint\x18\x02 \x01(\tR\fgrpcEndpoint\x12$\n" + "\x0emax_body_bytes\x18\x03 \x01(\x04R\fmaxBodyBytes\x12\x18\n" + - "\atimeout\x18\x04 \x01(\tR\atimeout*b\n" + + "\atimeout\x18\x04 \x01(\tR\atimeout\x12%\n" + + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + + "\baudience\x18\x06 \x01(\tR\baudience*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" +