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..82df6d65de 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -74,6 +74,25 @@ 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` alongside OIDC-shaped +discovery metadata at `/.well-known/openid-configuration`. HTTPS extensions can +pin an operator-provided CA while retaining endpoint-hostname verification. + +Extension credentials reuse the sandbox signing key and are separated from +sandbox-to-gateway admission tokens by exact audience and by an explicit +`typ` of `openshell-ext+jwt`, so a verifier that checks either one alone +cannot confuse the two. A service may advertise `expected_audience` in its +`Describe` manifest; a mismatch against operator configuration fails gateway +startup rather than surfacing as a runtime authentication error. A +registration may opt out of extension authentication entirely with +`allow_insecure_transport`, which permits a plaintext endpoint, attaches no +credential, and warns at every startup. Credential minting is bounded per +sandbox because it resolves the caller's effective policy. + 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..781e9686a2 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -101,6 +101,20 @@ 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. + +The slots live in a supervisor-owned `ExtensionCredentialStore` shared by every +gateway connection the supervisor opens, so the registry's clients and the +polling loop that rotates them observe the same credentials. Configuration +polling runs far more frequently than credentials expire, so the loop rotates +only when a credential is missing or has passed four fifths of its lifetime, +and bounds its sleep by the soonest rotation deadline. + 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..60507a12cf 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,19 @@ 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, + /// Opt out of extension authentication for this interceptor, permitting a + /// plaintext `http://` endpoint with no bearer credential. Development and + /// trusted-network deployments only. + #[serde(default)] + pub allow_insecure_transport: bool, /// Deterministic service ordering. Lower values run first. #[serde(default)] pub order: i32, @@ -655,6 +669,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 +1232,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..9e9775a831 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, ExtensionCredentialStore}; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; @@ -338,7 +339,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 +406,91 @@ async fn refresh_token_loop( } } +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +/// Registration names to request credentials for. +/// +/// Registrations the operator opted out of extension authentication have no +/// credential to request; asking for one is rejected by the gateway. +fn authenticated_service_names( + services: &[crate::proto::SupervisorMiddlewareService], +) -> Vec { + services + .iter() + .filter(|service| !service.allow_insecure_transport) + .map(|service| service.name.clone()) + .collect() +} + +async fn refresh_extension_credentials_with_client( + client: &mut OpenShellClient, + store: &ExtensionCredentialStore, + services: &[crate::proto::SupervisorMiddlewareService], +) -> Result> { + let names = authenticated_service_names(services); + 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. This + // is a superset of what the dedicated renewal loop would do, so letting it + // land early is harmless. + install_token_slot(&response.token)?; + + // Validate the whole response before mutating any slot, so a malformed or + // partial reply cannot leave the store half-rotated. + 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" + )); + } + validated.insert( + credential.service_name, + (credential.token, credential.expires_at_ms), + ); + } + if validated.len() != expected.len() { + return Err(miette::miette!( + "gateway omitted one or more requested extension credentials" + )); + } + + let now_ms = now_ms(); + let mut selected = HashMap::with_capacity(validated.len()); + for (name, (token, expires_at_ms)) in validated { + let slot = store + .install(&name, &token, expires_at_ms, now_ms) + .into_diagnostic() + .wrap_err("gateway returned an invalid extension credential")?; + selected.insert(name, slot); + } + Ok(selected) +} + /// 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 @@ -762,6 +850,10 @@ pub async fn fetch_provider_environment( pub struct CachedOpenShellClient { client: OpenShellClient, workspace: Arc>, + /// Extension credentials for this supervisor. Cloning the client shares + /// the store, so the middleware registry and the polling loop that rotates + /// it observe the same slots. + extension_credentials: ExtensionCredentialStore, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -844,11 +936,25 @@ pub struct ProviderEnvironmentResult { impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { + Self::connect_with_credentials(endpoint, ExtensionCredentialStore::new()).await + } + + /// Connect while sharing an existing credential store. + /// + /// The supervisor opens the gateway channel more than once (policy load, + /// then the polling loop). Both must observe the same slots, otherwise the + /// credentials handed to the middleware registry are not the ones the loop + /// rotates. + pub async fn connect_with_credentials( + endpoint: &str, + extension_credentials: ExtensionCredentialStore, + ) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); let client = connect(endpoint).await?; Ok(Self { client, workspace: Arc::new(tokio::sync::OnceCell::new()), + extension_credentials, }) } @@ -873,6 +979,67 @@ impl CachedOpenShellClient { Ok(result) } + /// The credential store backing this connection's extension clients. + #[must_use] + pub fn extension_credentials(&self) -> &ExtensionCredentialStore { + &self.extension_credentials + } + + /// Return the slots for `services`, rotating only when one is missing or + /// due. + /// + /// Configuration polling runs far more often than credentials expire, so + /// rotating unconditionally would mint tokens and re-run gateway policy + /// authorization roughly ninety times per useful rotation. + pub async fn extension_credentials_for( + &self, + services: &[crate::proto::SupervisorMiddlewareService], + ) -> Result> { + let names = authenticated_service_names(services); + if names.is_empty() { + return Ok(HashMap::new()); + } + if !self.extension_credentials.needs_refresh(&names, now_ms()) + && let Some(slots) = self.extension_credentials.slots_for(&names) + { + return Ok(slots); + } + self.refresh_extension_credentials(services).await + } + + /// Rotate credentials for `services` unconditionally. + 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, + &self.extension_credentials, + 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 names = self.extension_credentials.names(); + if names.is_empty() || !self.extension_credentials.needs_refresh(&names, now_ms()) { + return Ok(()); + } + let services = names + .into_iter() + .map(|name| crate::proto::SupervisorMiddlewareService { + name, + ..Default::default() + }) + .collect::>(); + 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..245be8cfee --- /dev/null +++ b/crates/openshell-extension-core/README.md @@ -0,0 +1,20 @@ +# 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, the extension JWT claim contract, refreshable bearer +credentials and the per-service store that holds them, and outbound gRPC +transport construction for HTTP, HTTPS, and Unix sockets. + +Outbound HTTPS authentication is selected through the shared +`ExtensionServerTrust` policy. Platform roots and operator-provided CA bundles +are implemented today. The non-exhaustive policy boundary allows future +SPIFFE X.509-SVID or public-key pinning support to be added once without +changing middleware and interceptor clients independently. Unix sockets retain +their local operating-system access boundary. + +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..4bf5d39189 --- /dev/null +++ b/crates/openshell-extension-core/src/jwt.rs @@ -0,0 +1,80 @@ +// 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); + +/// Explicit `typ` header value carried by every extension bearer token. +/// +/// Extension tokens and sandbox-to-gateway bootstrap tokens are signed by the +/// same key and differ only in their audience. Explicit typing (RFC 8725 +/// section 3.11) gives verifiers a second, independent discriminator: a +/// service that requires this `typ` cannot accept a sandbox bootstrap +/// credential even if it forgets to check `aud`. +pub const EXTENSION_JWT_TYP: &str = "openshell-ext+jwt"; + +/// `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..d24621f205 --- /dev/null +++ b/crates/openshell-extension-core/src/lib.rs @@ -0,0 +1,22 @@ +// 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 store; +mod transport; + +pub use auth::{BearerTokenInterceptor, BearerTokenSlot, TokenSlotError}; +pub use identity::{ExtensionAudience, ExtensionIdentity, ExtensionKind, IdentityError}; +pub use jwt::{ + EXTENSION_JWT_TYP, ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL, +}; +pub use store::ExtensionCredentialStore; +pub use transport::{ + ExtensionChannelConfig, ExtensionServerTrust, TransportError, connect_channel, +}; diff --git a/crates/openshell-extension-core/src/store.rs b/crates/openshell-extension-core/src/store.rs new file mode 100644 index 0000000000..2e6b94be25 --- /dev/null +++ b/crates/openshell-extension-core/src/store.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + +use crate::auth::{BearerTokenSlot, TokenSlotError}; + +/// Fraction of a credential's remaining lifetime to consume before rotating. +/// Matches the gateway-token renewal policy so both credentials refresh well +/// before expiry rather than at it. +const REFRESH_AT_FRACTION: (i64, i64) = (4, 5); + +/// Shortest interval a caller is asked to wait before retrying a rotation. +const MIN_REFRESH_DELAY_MS: i64 = 1_000; + +#[derive(Clone)] +struct Entry { + slot: BearerTokenSlot, + /// Wall-clock point after which this credential should be rotated. Derived + /// once at install time because a slot records only its expiry, not the + /// lifetime it was issued with. + refresh_after_ms: i64, +} + +/// Per-service extension credentials held by one supervisor. +/// +/// Cloning shares the underlying map, so the registry's middleware clients and +/// the polling loop that rotates them observe the same slots. Ownership is +/// explicit rather than process-global: tests construct independent stores and +/// cannot interfere with each other. +#[derive(Clone, Default)] +pub struct ExtensionCredentialStore { + inner: Arc>>, +} + +impl ExtensionCredentialStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Install or rotate one credential, returning the shared slot. + /// + /// An existing slot is updated in place so clients already holding a clone + /// pick up the new token without rebuilding their channel. + pub fn install( + &self, + name: &str, + token: &str, + expires_at_ms: i64, + now_ms: i64, + ) -> Result { + let refresh_after_ms = refresh_deadline_ms(expires_at_ms, now_ms); + let mut slots = self.write(); + if let Some(entry) = slots.get_mut(name) { + entry.slot.update(token, expires_at_ms)?; + entry.refresh_after_ms = refresh_after_ms; + return Ok(entry.slot.clone()); + } + let slot = BearerTokenSlot::new(token, expires_at_ms)?; + slots.insert( + name.to_string(), + Entry { + slot: slot.clone(), + refresh_after_ms, + }, + ); + Ok(slot) + } + + #[must_use] + pub fn get(&self, name: &str) -> Option { + self.read().get(name).map(|entry| entry.slot.clone()) + } + + /// Snapshot the slots for `names`, or `None` if any is absent. + #[must_use] + pub fn slots_for(&self, names: &[String]) -> Option> { + let slots = self.read(); + names + .iter() + .map(|name| { + slots + .get(name) + .map(|entry| (name.clone(), entry.slot.clone())) + }) + .collect() + } + + #[must_use] + pub fn names(&self) -> Vec { + self.read().keys().cloned().collect() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.read().is_empty() + } + + /// True when any requested credential is missing or due for rotation. + /// + /// Callers use this to avoid rotating on every configuration poll: a + /// 15-minute credential polled every 10 seconds would otherwise mint and + /// re-authorize roughly ninety times per useful rotation. + #[must_use] + pub fn needs_refresh(&self, names: &[String], now_ms: i64) -> bool { + let slots = self.read(); + names.iter().any(|name| { + slots + .get(name) + .is_none_or(|entry| entry.refresh_after_ms <= now_ms) + }) + } + + /// Bound `fallback` by the soonest rotation deadline so a caller's sleep + /// never overshoots a credential that must be rotated sooner. + #[must_use] + pub fn next_refresh_delay( + &self, + fallback: std::time::Duration, + now_ms: i64, + ) -> std::time::Duration { + let Some(earliest) = self + .read() + .values() + .map(|entry| entry.refresh_after_ms) + .min() + else { + return fallback; + }; + let remaining_ms = earliest.saturating_sub(now_ms).max(MIN_REFRESH_DELAY_MS); + fallback.min(std::time::Duration::from_millis( + u64::try_from(remaining_ms).unwrap_or(u64::MAX), + )) + } + + /// Drop credentials for services no longer in the installed registry. + /// + /// Detached slots are cleared before removal so any client still holding a + /// clone fails closed instead of continuing with a valid token. + pub fn retain(&self, names: &HashSet<&str>) { + self.write().retain(|name, entry| { + let keep = names.contains(name.as_str()); + if !keep { + entry.slot.clear(); + } + keep + }); + } +} + +impl std::fmt::Debug for ExtensionCredentialStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionCredentialStore") + .field("services", &self.names()) + .finish_non_exhaustive() + } +} + +fn refresh_deadline_ms(expires_at_ms: i64, now_ms: i64) -> i64 { + let remaining_ms = expires_at_ms.saturating_sub(now_ms); + if remaining_ms <= 0 { + return now_ms; + } + let consumed = remaining_ms + .saturating_mul(REFRESH_AT_FRACTION.0) + .checked_div(REFRESH_AT_FRACTION.1) + .unwrap_or(remaining_ms); + now_ms.saturating_add(consumed.max(MIN_REFRESH_DELAY_MS)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tonic::service::Interceptor; + + use super::*; + + const NOW: i64 = 1_000_000; + + /// Interceptor assertions compare against the real clock, so tests that + /// exercise a live slot need an expiry in the actual future rather than + /// the synthetic `NOW` used for deterministic scheduling arithmetic. + const FAR_FUTURE_MS: i64 = 4_102_444_800_000; + + #[test] + fn rotation_updates_existing_slots_in_place() { + let store = ExtensionCredentialStore::new(); + let first = store + .install("guard", "first-secret", FAR_FUTURE_MS, NOW) + .unwrap(); + let mut interceptor = first.interceptor(); + + store + .install("guard", "second-secret", FAR_FUTURE_MS, NOW) + .unwrap(); + + // The registry's client holds a clone from the first install; rotation + // must reach it without rebuilding the channel. + assert_eq!( + interceptor + .call(tonic::Request::new(())) + .unwrap() + .metadata() + .get("authorization") + .unwrap(), + "Bearer second-secret" + ); + } + + #[test] + fn refresh_is_due_only_after_four_fifths_of_the_lifetime() { + let store = ExtensionCredentialStore::new(); + let names = vec!["guard".to_string()]; + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + + assert!(!store.needs_refresh(&names, NOW)); + assert!(!store.needs_refresh(&names, NOW + 719_000)); + assert!(store.needs_refresh(&names, NOW + 720_001)); + + // An unknown service always forces a refresh so newly attached + // middleware acquires a credential before it is used. + assert!(store.needs_refresh(&["other".to_string()], NOW)); + } + + #[test] + fn poll_delay_is_bounded_by_the_soonest_rotation() { + let store = ExtensionCredentialStore::new(); + let fallback = Duration::from_secs(10); + assert_eq!(store.next_refresh_delay(fallback, NOW), fallback); + + // 15-minute credential: rotation is due long after the poll interval, + // so polling cadence is unchanged. + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + assert_eq!(store.next_refresh_delay(fallback, NOW), fallback); + + // Short-lived credential: the caller must wake before it is stale. + store.install("brief", "secret", NOW + 5_000, NOW).unwrap(); + assert_eq!( + store.next_refresh_delay(fallback, NOW), + Duration::from_secs(4) + ); + } + + #[test] + fn detached_credentials_are_cleared_before_removal() { + let store = ExtensionCredentialStore::new(); + let slot = store + .install("detached", "secret", FAR_FUTURE_MS, NOW) + .unwrap(); + store.install("kept", "secret", FAR_FUTURE_MS, NOW).unwrap(); + + store.retain(&HashSet::from(["kept"])); + + assert_eq!(store.names(), vec!["kept".to_string()]); + // A client still holding the detached slot must fail closed rather + // than keep using a token that is technically still valid. + assert_eq!( + slot.interceptor() + .call(tonic::Request::new(())) + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[test] + fn slots_for_requires_every_requested_service() { + let store = ExtensionCredentialStore::new(); + store + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + + assert!(store.slots_for(&["guard".to_string()]).is_some()); + assert!( + store + .slots_for(&["guard".to_string(), "missing".to_string()]) + .is_none() + ); + } + + #[test] + fn already_expired_credentials_are_immediately_due() { + let store = ExtensionCredentialStore::new(); + store.install("guard", "secret", NOW - 1, NOW).unwrap(); + assert!(store.needs_refresh(&["guard".to_string()], NOW)); + } + + #[test] + fn stores_are_independent() { + let first = ExtensionCredentialStore::new(); + let second = ExtensionCredentialStore::new(); + first + .install("guard", "secret", NOW + 900_000, NOW) + .unwrap(); + assert!(second.is_empty()); + } +} diff --git a/crates/openshell-extension-core/src/transport.rs b/crates/openshell-extension-core/src/transport.rs new file mode 100644 index 0000000000..2f9a633237 --- /dev/null +++ b/crates/openshell-extension-core/src/transport.rs @@ -0,0 +1,345 @@ +// 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); + +/// Trust policy used to authenticate a remote extension service. +/// +/// This enum is intentionally independent of middleware and interceptor +/// protocols. Future transport identities, such as SPIFFE X.509-SVIDs or +/// pinned public keys, can be added here without changing either caller. +#[derive(Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum ExtensionServerTrust { + /// Authenticate the HTTPS service with the platform trust store and the + /// endpoint's DNS name. + #[default] + PlatformRoots, + /// Authenticate the HTTPS service with only this PEM CA bundle and the + /// endpoint's DNS name. + CustomCaPem(Vec), +} + +impl std::fmt::Debug for ExtensionServerTrust { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PlatformRoots => formatter.write_str("PlatformRoots"), + Self::CustomCaPem(_) => formatter.write_str("CustomCaPem()"), + } + } +} + +/// Configuration for an outbound extension gRPC channel. +#[derive(Clone, PartialEq, Eq)] +pub struct ExtensionChannelConfig { + endpoint: String, + server_trust: ExtensionServerTrust, +} + +impl ExtensionChannelConfig { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + server_trust: ExtensionServerTrust::default(), + } + } + + /// Select how the remote extension service is authenticated. + #[must_use] + pub fn with_server_trust(mut self, server_trust: ExtensionServerTrust) -> Self { + self.server_trust = server_trust; + self + } + + /// 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(self, custom_ca_pem: impl Into>) -> Self { + self.with_server_trust(ExtensionServerTrust::CustomCaPem(custom_ca_pem.into())) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn server_trust(&self) -> &ExtensionServerTrust { + &self.server_trust + } + + /// Returns whether HTTPS verification uses an operator-provided CA bundle. + pub fn has_custom_ca(&self) -> bool { + matches!(&self.server_trust, ExtensionServerTrust::CustomCaPem(_)) + } +} + +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("server_trust", &self.server_trust) + .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 = match &config.server_trust { + ExtensionServerTrust::PlatformRoots => ClientTlsConfig::new().with_enabled_roots(), + ExtensionServerTrust::CustomCaPem(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 !matches!(&config.server_trust, ExtensionServerTrust::PlatformRoots) && !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 server_trust_is_an_explicit_shared_policy() { + let default = ExtensionChannelConfig::new("https://middleware.example"); + assert!(matches!( + default.server_trust(), + ExtensionServerTrust::PlatformRoots + )); + + let custom = + default.with_server_trust(ExtensionServerTrust::CustomCaPem(b"test CA".to_vec())); + assert!(matches!( + custom.server_trust(), + ExtensionServerTrust::CustomCaPem(pem) if pem == b"test CA" + )); + } + + #[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..d4fa7b85a4 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,16 @@ 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, ExtensionServerTrust, + 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 +135,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 +173,31 @@ 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(_) if config.allow_insecure_transport => BearerTokenInterceptor::disabled(), + 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, @@ -210,6 +224,11 @@ impl ExecutionPlan { )) })? .into_inner(); + validate_expected_audience( + &config, + &manifest.expected_audience, + token_slots.is_some(), + )?; let service_default = match config.binding_policy { GatewayInterceptorBindingPolicy::Dynamic => { let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; @@ -348,6 +367,55 @@ impl ExecutionPlan { } } +/// Reject an interceptor whose configured audience differs from the one the +/// service says it verifies. +/// +/// Without this the two values are configured independently on either side of +/// the boundary and a mismatch surfaces only as an opaque runtime 401 on every +/// intercepted call. A service that does not advertise an audience is accepted +/// unchanged. +fn validate_expected_audience( + config: &GatewayInterceptorConfig, + advertised: &str, + authenticated: bool, +) -> Result<()> { + if !authenticated || config.allow_insecure_transport || advertised.is_empty() { + return Ok(()); + } + let configured = config.resolved_audience(); + if advertised != configured { + return Err(InterceptorError::Config(format!( + "interceptor '{}' expects audience '{advertised}' but the gateway \ + is configured to mint '{configured}'", + config.name + ))); + } + Ok(()) +} + +fn validate_authenticated_slots( + configs: &[GatewayInterceptorConfig], + token_slots: Option<&BTreeMap>, +) -> Result<()> { + let Some(token_slots) = token_slots else { + return Ok(()); + }; + for config in configs { + // Registrations the operator opted out of extension authentication + // intentionally have no slot; everything else must fail closed. + if config.allow_insecure_transport { + continue; + } + 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 +925,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; - } - Endpoint::from_shared(endpoint.to_string()) - .map_err(|e| { - InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) - })? - .connect() - .await - .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) -} - -#[cfg(unix)] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - let display = path.display().to_string(); - Endpoint::from_static("http://[::]:50051") - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - async move { UnixStream::connect(path).await.map(TokioIo::new) } - })) - .await - .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) -} - -#[cfg(not(unix))] -async fn connect_unix_endpoint(path: PathBuf) -> Result { - Err(InterceptorError::Config(format!( - "unix interceptor endpoints are not supported on this platform: {}", - path.display() - ))) +async fn 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_server_trust(ExtensionServerTrust::CustomCaPem(pem)); + } + 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 +1020,91 @@ 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(); + } + + #[test] + fn advertised_audience_mismatch_fails_at_startup() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "https://governance.example".to_string(), + ..GatewayInterceptorConfig::default() + }; + + // Matching and unadvertised audiences both pass. + validate_expected_audience(&config, "", true).expect("unadvertised audience is accepted"); + validate_expected_audience( + &config, + "urn:openshell:extension:interceptor:governance", + true, + ) + .expect("matching audience is accepted"); + + // A mismatch would otherwise surface only as an opaque runtime 401. + let error = validate_expected_audience(&config, "urn:example:something-else", true) + .expect_err("mismatched audience must fail closed at startup"); + let message = error.to_string(); + assert!(message.contains("urn:example:something-else")); + assert!(message.contains("urn:openshell:extension:interceptor:governance")); + + // With gateway JWT signing disabled no token is minted at all, so the + // advertised audience is not something OpenShell can be wrong about. + validate_expected_audience(&config, "urn:example:something-else", false) + .expect("unauthenticated gateways skip the handshake"); + + // The check likewise does not apply where the registration opted out. + let opted_out = GatewayInterceptorConfig { + allow_insecure_transport: true, + ..config + }; + validate_expected_audience(&opted_out, "urn:example:something-else", true) + .expect("opted-out interceptors mint no token to mismatch"); + } + + #[test] + fn opted_out_interceptors_do_not_require_a_token_slot() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + allow_insecure_transport: true, + ..GatewayInterceptorConfig::default() + }; + validate_authenticated_slots(&[config], Some(&BTreeMap::new())) + .expect("explicit opt-out needs no credential"); + } + + #[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..4598740e2c 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -155,6 +155,11 @@ pub async fn run_sandbox( None }; + // Extension credentials are owned by this supervisor and shared by every + // gateway connection it opens, so the middleware registry's bearer slots + // and the policy poll loop that rotates them stay the same objects. + let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); + // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); @@ -183,6 +188,7 @@ pub async fn run_sandbox( openshell_endpoint.clone(), policy_rules, policy_data, + &extension_credentials, ) .await? }; @@ -597,6 +603,7 @@ pub async fn run_sandbox( middleware_registry_status, sidecar_control_publisher: sidecar_control_publisher.clone(), workspace_tx, + extension_credentials: extension_credentials.clone(), }; tokio::spawn(async move { @@ -1850,6 +1857,7 @@ async fn load_policy( openshell_endpoint: Option, policy_rules: Option, policy_data: Option, + extension_credentials: &openshell_extension_core::ExtensionCredentialStore, ) -> Result<( SandboxPolicy, Option>, @@ -2068,10 +2076,26 @@ 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(); + let extension_credentials = extension_credentials.clone(); + async move { + // Share the supervisor's store so the slots installed here are + // the ones the policy poll loop later rotates in place. + let credentials = + openshell_core::grpc_client::CachedOpenShellClient::connect_with_credentials( + endpoint, + extension_credentials, + ) + .await? + .refresh_extension_credentials(&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 +2306,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) @@ -2639,8 +2668,10 @@ struct PolicyPollLoopContext { middleware_registry_status: MiddlewareRegistryStatus, sidecar_control_publisher: Option, workspace_tx: tokio::sync::watch::Sender, + extension_credentials: openshell_extension_core::ExtensionCredentialStore, } +#[cfg(test)] async fn connect_middleware_registry( services: &[openshell_core::proto::SupervisorMiddlewareService], ) -> Result { @@ -2651,6 +2682,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(), @@ -2660,9 +2703,41 @@ async fn install_builtin_middleware_registry(opa_engine: &OpaEngine) -> Result<( opa_engine.replace_middleware_registry(registry) } +/// Wait the configured poll interval, but never past the point at which an +/// installed extension credential must be rotated. +fn next_poll_delay( + store: &openshell_extension_core::ExtensionCredentialStore, + interval: Duration, +) -> Duration { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| { + i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) + }); + store.next_refresh_delay(interval, now_ms) +} + +/// Drop credentials for services no longer in the installed registry. +/// +/// Call only after a registry swap succeeds, so a failed candidate cannot +/// invalidate the last-known-good clients. +fn retain_extension_credentials( + store: &openshell_extension_core::ExtensionCredentialStore, + installed: &[openshell_core::proto::SupervisorMiddlewareService], +) { + store.retain( + &installed + .iter() + .map(|service| service.name.as_str()) + .collect(), + ); +} + async fn reconcile_middleware_registry( opa_engine: &OpaEngine, desired_services: &[openshell_core::proto::SupervisorMiddlewareService], + credentials: &std::collections::HashMap, + extension_credentials: &openshell_extension_core::ExtensionCredentialStore, current_services: &mut Vec, status: &mut MiddlewareRegistryStatus, ) { @@ -2672,11 +2747,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(()) => { + retain_extension_credentials(extension_credentials, desired_services); current_services.clear(); current_services.extend_from_slice(desired_services); *status = MiddlewareRegistryStatus::Synchronized; @@ -2902,7 +2978,11 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { use openshell_core::proto::PolicySource; use std::sync::atomic::Ordering; - let client = CachedOpenShellClient::connect(&ctx.endpoint).await?; + let client = CachedOpenShellClient::connect_with_credentials( + &ctx.endpoint, + ctx.extension_credentials.clone(), + ) + .await?; let (status_sender, status_receiver) = tokio::sync::mpsc::unbounded_channel(); tokio::spawn(run_policy_status_reporter( client.clone(), @@ -2991,7 +3071,7 @@ 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(next_poll_delay(&ctx.extension_credentials, interval)).await; match client.poll_settings(&ctx.sandbox_id).await { Ok(result) => { let _ = ctx.workspace_tx.send(client.workspace()); @@ -2999,11 +3079,34 @@ 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; } } }; + // Reuse installed per-service credentials, rotating only when one is + // missing or due. Rotation happens on the existing gateway channel and + // updates slots in place, so it is independent of config revision and + // registry equality. + let middleware_credentials = match client + .extension_credentials_for(&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 +3141,8 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { reconcile_middleware_registry( &ctx.opa_engine, &result.supervisor_middleware_services, + &middleware_credentials, + &ctx.extension_credentials, &mut current_middleware_services, &mut middleware_registry_status, ) @@ -3147,6 +3252,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 +3349,10 @@ 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); + retain_extension_credentials( + &ctx.extension_credentials, + &result.supervisor_middleware_services, + ); middleware_registry_status = MiddlewareRegistryStatus::Synchronized; last_failed_runtime_revision = None; } @@ -3884,6 +3994,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/extension_mint_limit.rs b/crates/openshell-server/src/auth/extension_mint_limit.rs new file mode 100644 index 0000000000..0495cf0347 --- /dev/null +++ b/crates/openshell-server/src/auth/extension_mint_limit.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-sandbox bound on extension credential minting. +//! +//! Minting resolves the sandbox's effective policy to decide which extension +//! registrations the caller may hold credentials for. That resolution reads +//! policy history, settings, and the provider profile catalog, so an +//! unbounded caller inside a sandbox could impose real gateway cost by +//! requesting credentials in a loop. +//! +//! A well-behaved supervisor rotates at roughly 80% of the credential +//! lifetime — about once every twelve minutes for the fifteen-minute default — +//! plus a small burst at startup and on retry. The default bound leaves ample +//! headroom for that while capping what a compromised sandbox can drive. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const DEFAULT_WINDOW: Duration = Duration::from_secs(60); +const DEFAULT_MAX_PER_WINDOW: u32 = 10; + +/// Number of tracked sandboxes above which expired windows are pruned. +const PRUNE_THRESHOLD: usize = 1_024; + +struct Window { + started: Instant, + count: u32, +} + +pub struct ExtensionMintLimiter { + window: Duration, + max_per_window: u32, + windows: Mutex>, +} + +impl Default for ExtensionMintLimiter { + fn default() -> Self { + Self::new(DEFAULT_WINDOW, DEFAULT_MAX_PER_WINDOW) + } +} + +impl std::fmt::Debug for ExtensionMintLimiter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExtensionMintLimiter") + .field("window", &self.window) + .field("max_per_window", &self.max_per_window) + .finish_non_exhaustive() + } +} + +impl ExtensionMintLimiter { + #[must_use] + pub fn new(window: Duration, max_per_window: u32) -> Self { + Self { + window, + max_per_window, + windows: Mutex::new(HashMap::new()), + } + } + + /// Record one minting request, returning `false` when the sandbox has + /// exhausted its window. + pub fn try_acquire(&self, sandbox_id: &str) -> bool { + self.try_acquire_at(sandbox_id, Instant::now()) + } + + fn try_acquire_at(&self, sandbox_id: &str, now: Instant) -> bool { + if self.max_per_window == 0 { + return true; + } + let mut windows = self + .windows + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if windows.len() >= PRUNE_THRESHOLD { + windows.retain(|_, window| now.duration_since(window.started) < self.window); + } + + let window = windows.entry(sandbox_id.to_string()).or_insert(Window { + started: now, + count: 0, + }); + if now.duration_since(window.started) >= self.window { + window.started = now; + window.count = 0; + } + if window.count >= self.max_per_window { + return false; + } + window.count += 1; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_a_burst_then_refuses_within_the_window() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 3); + let start = Instant::now(); + + for _ in 0..3 { + assert!(limiter.try_acquire_at("sandbox-a", start)); + } + assert!(!limiter.try_acquire_at("sandbox-a", start)); + } + + #[test] + fn window_rollover_restores_capacity() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 1); + let start = Instant::now(); + + assert!(limiter.try_acquire_at("sandbox-a", start)); + assert!(!limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(59))); + assert!(limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(60))); + } + + #[test] + fn sandboxes_are_limited_independently() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 1); + let start = Instant::now(); + + assert!(limiter.try_acquire_at("sandbox-a", start)); + assert!(!limiter.try_acquire_at("sandbox-a", start)); + // One noisy sandbox must not deny credentials to any other. + assert!(limiter.try_acquire_at("sandbox-b", start)); + } + + #[test] + fn legitimate_rotation_cadence_stays_well_inside_the_default_bound() { + let limiter = ExtensionMintLimiter::default(); + let start = Instant::now(); + + // Startup acquires once, then the poll loop rotates at ~80% of a + // 15-minute credential. Even compressed into a single window that is + // far below the bound. + for minute in 0..12 { + assert!(limiter.try_acquire_at("sandbox-a", start + Duration::from_secs(minute * 60))); + } + } + + #[test] + fn a_zero_bound_disables_limiting() { + let limiter = ExtensionMintLimiter::new(Duration::from_secs(60), 0); + let start = Instant::now(); + for _ in 0..1_000 { + assert!(limiter.try_acquire_at("sandbox-a", start)); + } + } +} diff --git a/crates/openshell-server/src/auth/http.rs b/crates/openshell-server/src/auth/http.rs index f0e7011658..df95f35fd7 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -18,7 +18,7 @@ use axum::{ Json, Router, extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse}, routing::get, }; @@ -59,9 +59,104 @@ 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)) + .route( + "/.well-known/openid-configuration", + get(gateway_discovery_handler), + ) .with_state(state) } +/// Publish OIDC-shaped discovery metadata for extension services. +/// +/// This lets an extension be configured with only the trusted gateway URL: +/// it learns the exact expected `iss` and the `jwks_uri` from one document +/// instead of having both provisioned separately. +/// +/// Like the equivalent Kubernetes endpoint, this is OIDC-shaped rather than +/// OIDC-compliant: `issuer` is the gateway identity (`openshell-gateway:`), +/// not the URL this document is served from. Verifiers must compare `iss` +/// against that value, and must not infer trust from the document's location +/// alone. The serving TLS connection is what authenticates the gateway. +async fn gateway_discovery_handler( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> impl IntoResponse { + gateway_discovery_response( + state.sandbox_jwt_authenticator.as_deref(), + &document_base_url(&headers, &uri), + ) +} + +/// Reconstruct the externally visible base URL for absolute discovery links. +/// +/// HTTP/1.1 carries `Host`; HTTP/2 carries `:authority`, which axum surfaces +/// on the request URI. A terminating proxy may report the original scheme in +/// `X-Forwarded-Proto`; otherwise assume TLS, since the extension contract +/// requires fetching this document over an authenticated connection. +fn document_base_url(headers: &HeaderMap, uri: &Uri) -> Option { + let authority = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .filter(|host| !host.is_empty()) + .map(ToString::to_string) + .or_else(|| uri.authority().map(ToString::to_string))?; + let scheme = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|proto| proto.split(',').next()) + .map(str::trim) + .filter(|proto| !proto.is_empty()) + .unwrap_or("https"); + Some(format!("{scheme}://{authority}")) +} + +fn gateway_discovery_response( + authenticator: Option<&crate::auth::sandbox_jwt::SandboxJwtAuthenticator>, + base_url: &Option, +) -> axum::response::Response { + let Some(authenticator) = authenticator else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some(base_url) = base_url else { + // Without a usable authority the `jwks_uri` cannot be absolute, and a + // relative value would be worse than none: a verifier could resolve it + // against the wrong origin. + return StatusCode::BAD_REQUEST.into_response(); + }; + Json(serde_json::json!({ + "issuer": authenticator.issuer(), + "jwks_uri": format!("{base_url}/.well-known/jwks.json"), + "id_token_signing_alg_values_supported": ["EdDSA"], + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "scopes_supported": ["openid"], + "claims_supported": [ + "iss", "aud", "sub", "iat", "exp", "jti", "caller_kind", "sandbox_id" + ], + })) + .into_response() +} + +/// 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 +569,8 @@ fn render_waiting_page(callback_port: u16, code: &str) -> String { #[cfg(test)] mod tests { use super::*; + use axum::body::to_bytes; + use openshell_bootstrap::jwt::generate_jwt_key; #[test] fn extract_cookie_finds_value() { @@ -586,4 +683,109 @@ 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); + } + + fn test_authenticator() -> crate::auth::sandbox_jwt::SandboxJwtAuthenticator { + let material = generate_jwt_key().expect("key"); + crate::auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem( + material.public_key_pem.as_bytes(), + material.kid, + "gateway-a", + ) + .expect("authenticator") + } + + #[tokio::test] + async fn discovery_document_advertises_issuer_identity_and_absolute_jwks_uri() { + let authenticator = test_authenticator(); + let response = gateway_discovery_response( + Some(&authenticator), + &Some("https://gateway.example:8443".to_string()), + ); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("body"); + let document: serde_json::Value = serde_json::from_slice(&body).expect("json"); + + // The issuer is the gateway identity, not the serving URL. Extensions + // compare `iss` against this exact value. + assert_eq!(document["issuer"], "openshell-gateway:gateway-a"); + assert_eq!( + document["jwks_uri"], + "https://gateway.example:8443/.well-known/jwks.json" + ); + assert_eq!( + document["id_token_signing_alg_values_supported"][0], + "EdDSA" + ); + } + + #[test] + fn discovery_document_requires_a_key_and_a_resolvable_authority() { + assert_eq!( + gateway_discovery_response(None, &Some("https://gateway.example".to_string())).status(), + StatusCode::NOT_FOUND + ); + // A relative jwks_uri could be resolved against the wrong origin, so + // refuse to emit a document rather than emit an ambiguous one. + assert_eq!( + gateway_discovery_response(Some(&test_authenticator()), &None).status(), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn base_url_prefers_host_header_and_honours_forwarded_scheme() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "gateway.example:8443".parse().unwrap()); + assert_eq!( + document_base_url(&headers, &Uri::from_static("/")), + Some("https://gateway.example:8443".to_string()) + ); + + headers.insert("x-forwarded-proto", "http, https".parse().unwrap()); + assert_eq!( + document_base_url(&headers, &Uri::from_static("/")), + Some("http://gateway.example:8443".to_string()) + ); + + // HTTP/2 has no Host header; axum surfaces `:authority` on the URI. + assert_eq!( + document_base_url( + &HeaderMap::new(), + &Uri::from_static("https://h2.example/.well-known/openid-configuration") + ), + Some("https://h2.example".to_string()) + ); + assert_eq!( + document_base_url(&HeaderMap::new(), &Uri::from_static("/")), + None + ); + } } diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index c26fac08ad..bedbebe015 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -11,6 +11,7 @@ pub mod authenticator; pub mod authz; pub mod descriptor_authz; +pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 39f5982ca0..29aff8179c 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -18,13 +18,22 @@ 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::{ + EXTENSION_JWT_TYP, 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 +42,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 +153,70 @@ 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()); + // Explicit typing so an extension that requires this `typ` cannot be + // handed a sandbox-to-gateway bootstrap token signed by the same key. + header.typ = Some(EXTENSION_JWT_TYP.to_string()); + 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 +228,7 @@ pub struct SandboxJwtAuthenticator { kid: String, issuer: String, audience: String, + jwks: GatewayJwks, } impl std::fmt::Debug for SandboxJwtAuthenticator { @@ -153,15 +245,30 @@ 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 + } + + /// The exact `iss` claim carried by every token this gateway mints. + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + #[allow(clippy::result_large_err)] fn validate_bearer(&self, token: &str) -> Result, Status> { let header = decode_header(token).map_err(|e| { @@ -201,6 +308,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 +415,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 +534,177 @@ 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_tokens_are_explicitly_typed_and_sandbox_tokens_are_not() { + 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::from_secs(3600), + ) + .expect("issuer"); + + let extension = issuer + .mint_extension_token( + &extension_audience("urn:openshell:extension:middleware:scanner"), + ExtensionCallerKind::Gateway, + None, + Duration::from_secs(300), + ) + .expect("extension token"); + assert_eq!( + decode_header(&extension.token).unwrap().typ.as_deref(), + Some(EXTENSION_JWT_TYP) + ); + + // The discriminator is only useful if the sandbox bootstrap token does + // not carry it. A verifier requiring `openshell-ext+jwt` must reject a + // gateway admission credential even though both share a signing key. + let sandbox = issuer.mint("sandbox-a").expect("sandbox token"); + assert_ne!( + decode_header(&sandbox.token).unwrap().typ.as_deref(), + Some(EXTENSION_JWT_TYP) + ); + } + + #[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..30f504da72 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,20 @@ 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, + /// Opt out of extension authentication for this registration, permitting a + /// plaintext `http://` endpoint with no bearer credential. Development and + /// trusted-network deployments only. + #[serde(default)] + pub allow_insecure_transport: bool, /// 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 +243,75 @@ 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, + ), + allow_insecure_transport: config.allow_insecure_transport, + }) + } +} + +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 +345,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 +697,159 @@ 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()), + allow_insecure_transport: false, 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, + allow_insecure_transport: false, + 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, + allow_insecure_transport: false, + 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, + allow_insecure_transport: false, + 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..dca563a715 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,41 @@ 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 if !state + .extension_mint_limiter + .try_acquire(&sandbox.sandbox_id) + { + // Minting resolves the sandbox's effective policy, so an unbounded + // caller could impose real gateway cost from inside a sandbox. The + // supervisor keeps its last-known-good slots on error and retries at + // its normal cadence, so refusing is safe. + warn!( + sandbox_id = %sandbox.sandbox_id, + "extension credential minting rate limit exceeded" + ); + return Err(Status::resource_exhausted( + "extension credential minting rate limit exceeded for this sandbox", + )); + } 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 +192,81 @@ 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" + )) + })?; + if service.allow_insecure_transport { + return Err(Status::failed_precondition(format!( + "extension service '{name}' opted out of extension authentication; \ + no credential is minted for it" + ))); + } + 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 +396,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 +408,99 @@ 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 refresh_refuses_extension_credentials_past_the_per_sandbox_bound() { + let state = state_with_issuer().await; + // The gateway credential path is unaffected; only requests that carry + // extension service names consume the bound. + for _ in 0..10 { + assert!(state.extension_mint_limiter.try_acquire("sandbox-a")); + } + assert!(!state.extension_mint_limiter.try_acquire("sandbox-a")); + assert!(state.extension_mint_limiter.try_acquire("sandbox-b")); + } + + #[tokio::test] + async fn opted_out_registrations_never_receive_a_minted_credential() { + let state = state_with_issuer().await; + let issuer = state.sandbox_jwt_issuer.as_deref().expect("issuer"); + let available = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "legacy-guard".to_string(), + audience: "urn:example:legacy-guard".to_string(), + allow_insecure_transport: true, + ..Default::default() + }]; + + // The registration is policy-selected, so authorization passes; the + // opt-out is what withholds the credential. A supervisor must not be + // able to obtain a bearer token it would then send over plaintext. + let error = mint_extension_credentials( + issuer, + "sandbox-a", + &["legacy-guard".to_string()], + &available, + ) + .expect_err("opted-out registration must not mint a credential"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + } + + #[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 +557,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 +582,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 +623,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..73134c2e73 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, ExtensionKind, 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,153 @@ 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}; + +/// Deriving `Debug` is safe here: `BearerTokenSlot` renders only its expiry, +/// and an extension audience is configuration rather than secret material. +#[derive(Debug)] +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) + } +} + +/// Mint the gateway-caller credential for one extension registration. +/// +/// Returns `Ok(None)` when the operator has explicitly opted the registration +/// out of extension authentication. The opt-out is deliberately loud: it +/// downgrades a security boundary, so it is reported once per registration at +/// startup rather than being silently tolerated. +fn mint_gateway_extension_credential( + issuer: &Arc, + kind: ExtensionKind, + name: &str, + audience: &str, + endpoint: &str, + allow_insecure_transport: bool, +) -> Result> { + // A middleware endpoint must be reachable from sandbox supervisors, so a + // gateway-local Unix socket is only an option for interceptors. + let accepted = match kind { + ExtensionKind::Middleware => "https://", + ExtensionKind::Interceptor => "https:// or unix://", + }; + if allow_insecure_transport { + warn!( + extension = %name, + endpoint = %endpoint, + "extension authentication is DISABLED for this registration by \ + allow_insecure_transport; OpenShell attaches no caller credential \ + and the service cannot distinguish OpenShell from any other \ + network client. Use {accepted} with the opt-out removed outside \ + trusted-network development deployments." + ); + return Ok(None); + } + let transport_supported = endpoint.starts_with("https://") + || (matches!(kind, ExtensionKind::Interceptor) && endpoint.starts_with("unix://")); + if !transport_supported { + return Err(Error::config(format!( + "authenticated {kind} '{name}' must use {accepted}; set \ + allow_insecure_transport = true to opt this registration out of \ + extension authentication instead" + ))); + } + 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(Some(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; @@ -167,6 +317,10 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + /// Per-sandbox bound on extension credential minting, which resolves the + /// caller's effective policy on every request. + pub(crate) extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter, + /// Immutable gateway interceptor execution plan. `None` when disabled. pub(crate) gateway_interceptors: Option, @@ -254,6 +408,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, sandbox_jwt_issuer: None, @@ -293,6 +448,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 +509,42 @@ 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 { + if let Some(credential) = mint_gateway_extension_credential( + issuer, + ExtensionKind::Middleware, + ®istration.name, + ®istration.audience, + ®istration.grpc_endpoint, + registration.allow_insecure_transport, + )? { + 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 +594,31 @@ 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(); + if let Some(credential) = mint_gateway_extension_credential( + issuer, + ExtensionKind::Interceptor, + &interceptor.name, + audience.as_ref(), + &interceptor.grpc_endpoint, + interceptor.allow_insecure_transport, + )? { + 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 +646,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 @@ -1073,10 +1281,11 @@ pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { #[cfg(test)] mod tests { use super::{ - BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, GatewayListenerScope, - MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, - bind_gateway_listeners, classify_initial_bytes, configured_compute_driver, - is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, + BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, + GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, + allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, + configured_compute_driver, is_benign_tls_handshake_failure, + kubernetes_sandbox_jwt_expiry_disabled, mint_gateway_extension_credential, serve_gateway_listener, }; use openshell_core::{ @@ -1101,6 +1310,94 @@ mod tests { tls_test_utils::{generate_test_certs_with_ca, install_rustls_provider}, }; + fn extension_test_issuer() -> Arc { + let material = openshell_bootstrap::jwt::generate_jwt_key().expect("jwt key"); + Arc::new( + crate::auth::sandbox_jwt::SandboxJwtIssuer::from_pem( + material.signing_key_pem.as_bytes(), + material.kid, + "gateway-a", + Duration::from_secs(900), + ) + .expect("issuer"), + ) + } + + #[test] + fn plaintext_extension_endpoint_is_rejected_unless_explicitly_opted_out() { + let issuer = extension_test_issuer(); + + // Default posture: a plaintext endpoint cannot carry a bearer + // credential, so startup fails and names the opt-out. + let error = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "http://host.openshell.internal:50051", + false, + ) + .expect_err("plaintext endpoint must not silently downgrade"); + assert!(error.to_string().contains("allow_insecure_transport")); + + // Explicit opt-out starts the gateway with no credential attached. + assert!( + mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "http://host.openshell.internal:50051", + true, + ) + .expect("opt-out must be permitted") + .is_none() + ); + } + + #[test] + fn authenticated_extension_endpoints_mint_a_credential() { + let issuer = extension_test_issuer(); + let credential = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "https://content-guard.example:50051", + false, + ) + .expect("credential") + .expect("authenticated endpoint mints a credential"); + assert_eq!(credential.name, "content-guard"); + assert!(credential.slot.expires_at_ms().is_some_and(|ms| ms > 0)); + + // Unix sockets are gateway-local, so only interceptors can use them. + // A middleware endpoint must also be reachable from every supervisor. + let error = mint_gateway_extension_credential( + &issuer, + ExtensionKind::Middleware, + "content-guard", + "urn:openshell:extension:middleware:content-guard", + "unix:///run/openshell/content-guard.sock", + false, + ) + .expect_err("middleware cannot be reached over a gateway-local socket"); + assert!(error.to_string().contains("must use https://")); + + assert!( + mint_gateway_extension_credential( + &issuer, + ExtensionKind::Interceptor, + "quota", + "urn:openshell:extension:interceptor:quota", + "unix:///run/openshell/interceptors/quota.sock", + false, + ) + .expect("credential") + .is_some() + ); + } + fn test_driver_startup<'a>( config: &'a Config, file: Option<&'a super::config_file::ConfigFile>, diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 114201b982..7a7125dcc3 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -1466,6 +1466,7 @@ mod tests { failure_policy: "fail_open".to_string(), }], provider_profiles: false, + expected_audience: String::new(), })) } diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index e23a228f05..27fc67d4bb 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -55,6 +55,7 @@ impl SupervisorMiddleware for BuiltinMiddlewareService { name: BUILTIN_REGEX.into(), service_version: env!("CARGO_PKG_VERSION").into(), bindings: vec![regex::describe()], + expected_audience: String::new(), })) } 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..ddc812741f 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; @@ -532,14 +530,45 @@ fn validate_external_manifest( registration: &SupervisorMiddlewareService, manifest: &MiddlewareManifest, operator_max_body_bytes: usize, + authenticated: bool, ) -> Result<()> { validate_manifest_bindings( &format!("external middleware registration '{}'", registration.name), manifest, Some(operator_max_body_bytes), + )?; + validate_expected_audience( + ®istration.name, + ®istration.audience, + &manifest.expected_audience, + authenticated && !registration.allow_insecure_transport, ) } +/// Reject a registration whose configured audience differs from the one the +/// service says it verifies. +/// +/// Without this the two values are configured independently on either side of +/// the boundary and a mismatch surfaces only as an opaque runtime 401 on every +/// call. A service that does not advertise an audience is accepted unchanged. +fn validate_expected_audience( + registration_name: &str, + configured: &str, + advertised: &str, + authenticated: bool, +) -> Result<()> { + if !authenticated || advertised.is_empty() { + return Ok(()); + } + if advertised != configured { + return Err(miette!( + "middleware registration '{registration_name}' expects audience \ + '{advertised}' but OpenShell is configured to mint '{configured}'" + )); + } + Ok(()) +} + /// External diagnostic text is untrusted and may contain request data. Keep /// only values derived from the validated, operator-owned registration name /// and numeric finding counts; do not carry per-request free-form text into @@ -670,6 +699,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 +785,28 @@ impl MiddlewareRegistry { registration.name ) })?; + // A registration the operator opted out of extension + // authentication carries no credential by design. Every other + // registration must have one, or the connection fails closed + // rather than silently downgrading to an unauthenticated call. + let bearer = credentials + .filter(|_| !registration.allow_insecure_transport) + .map(|credentials| { + credentials.get(®istration.name).cloned().ok_or_else(|| { + miette!( + "middleware registration '{}' is missing its extension credential", + registration.name + ) + }) + }) + .transpose()?; + let authenticated = bearer.is_some(); let service = Arc::new( remote::RemoteMiddlewareService::connect( ®istration.name, ®istration.grpc_endpoint, + ®istration.tls_ca_cert_pem, + bearer, ) .await?, ); @@ -758,7 +824,12 @@ impl MiddlewareRegistry { safe_reason(&error.to_string()) ) })?; - validate_external_manifest(®istration, &manifest, operator_max_body_bytes)?; + validate_external_manifest( + ®istration, + &manifest, + operator_max_body_bytes, + authenticated, + )?; let manifest_cell = OnceCell::new(); manifest_cell .set(manifest) @@ -1423,6 +1494,32 @@ mod tests { use openshell_supervisor_middleware_builtins::{BUILTIN_REGEX, services}; use tokio_stream::wrappers::TcpListenerStream; + #[test] + fn advertised_audience_mismatch_fails_registration() { + let configured = "urn:openshell:extension:middleware:content-guard"; + + // Matching and unadvertised audiences both pass. + validate_expected_audience("content-guard", configured, "", true) + .expect("unadvertised audience is accepted"); + validate_expected_audience("content-guard", configured, configured, true) + .expect("matching audience is accepted"); + + // A mismatch would otherwise surface only as an opaque runtime 401 on + // every evaluated request. + let error = + validate_expected_audience("content-guard", configured, "urn:example:stale", true) + .expect_err("mismatched audience must fail closed"); + let message = error.to_string(); + assert!(message.contains("urn:example:stale")); + assert!(message.contains(configured)); + + // The check does not apply where no credential is attached at all, + // whether because the registration opted out or because the gateway + // has no signing key configured. + validate_expected_audience("content-guard", configured, "urn:example:stale", false) + .expect("an unauthenticated call has no audience to mismatch"); + } + fn builtin_runner() -> ChainRunner { ChainRunner::new( services() @@ -1689,6 +1786,7 @@ mod tests { max_body_bytes: self.max_body_bytes, timeout: String::new(), }], + expected_audience: String::new(), })) } @@ -1738,6 +1836,7 @@ mod tests { max_body_bytes: 4096, timeout: self.binding_timeout.clone(), }], + expected_audience: String::new(), })) } @@ -1791,6 +1890,7 @@ mod tests { max_body_bytes: 256 * 1024, timeout: String::new(), }], + expected_audience: String::new(), })) } @@ -2055,6 +2155,7 @@ mod tests { max_body_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), })) } @@ -2114,6 +2215,7 @@ mod tests { max_body_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), })) } @@ -2312,7 +2414,7 @@ mod tests { .into_inner(); let operator_max_body_bytes = usize::try_from(registration.max_body_bytes).unwrap(); let operator_timeout = validate_registration(®istration).expect("valid registration"); - validate_external_manifest(®istration, &manifest, operator_max_body_bytes) + validate_external_manifest(®istration, &manifest, operator_max_body_bytes, false) .expect("valid external manifest"); let manifest_cell = OnceCell::new(); manifest_cell.set(manifest).expect("manifest cache"); @@ -2529,8 +2631,9 @@ mod tests { max_body_bytes: 4096, timeout: String::new(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4097) + let error = validate_external_manifest(®istration, &manifest, 4097, false) .expect_err("operator limit must fit capability"); assert!(error.to_string().contains("exceeds")); } @@ -2555,8 +2658,9 @@ mod tests { max_body_bytes: u64::MAX, timeout: String::new(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) + let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("extreme advertised body limit must be rejected"); assert!(error.to_string().contains("platform maximum")); } @@ -2574,9 +2678,10 @@ mod tests { name: "example/service".into(), service_version: "test".into(), bindings: vec![binding(), binding()], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) + let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("one service cannot advertise two bindings for the same pair"); assert!( error @@ -2651,8 +2756,9 @@ mod tests { max_body_bytes: 4096, timeout: timeout.into(), }], + expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096) + let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("out-of-bounds binding timeout must be rejected"); assert!(error.to_string().contains("invalid timeout")); } diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 30ea5a74bb..2ad844ee11 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,36 @@ use openshell_core::proto::{ HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, }; -use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use openshell_extension_core::{ + BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, + 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_server_trust(ExtensionServerTrust::CustomCaPem(tls_ca_cert_pem.to_vec())); } - - let channel = endpoint - .connect_timeout(CONNECT_TIMEOUT) - .connect() + let channel = connect_channel(&config) .await .into_diagnostic() .wrap_err_with(|| { @@ -57,6 +45,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-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index fa2eab4ad7..055811ab0f 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -3225,6 +3225,7 @@ network_policies: max_body_bytes: 8192, timeout: String::new(), }], + expected_audience: String::new(), }, )) } @@ -3713,6 +3714,7 @@ network_policies: max_body_bytes: self.max_body_bytes, timeout: String::new(), }], + expected_audience: String::new(), })) } 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..62d97b4c9d 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,15 @@ 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`. + +Return your expected audience in the `expected_audience` field of your `Describe` manifest. The gateway compares it against the configured value and refuses to start on a mismatch, so a misconfigured audience fails at startup instead of returning an opaque 401 on every intercepted call. Leave the field empty to skip the check. + +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. `GET /.well-known/openid-configuration` returns that issuer alongside `jwks_uri`, so an interceptor configured with only the gateway URL can discover both; it is OIDC-shaped rather than OIDC-compliant, because `issuer` is the gateway identity rather than the serving URL. Fetch it over authenticated TLS at the trusted gateway URL, or provision the public signing key through the deployment when the interceptor cannot reach the gateway. Pin `alg` to `EdDSA`, require `typ` to be exactly `openshell-ext+jwt`, and validate `kid`, signature, expected issuer, exact audience, positive expiry, and caller kind. + +Set `allow_insecure_transport = true` on an interceptor to keep a plaintext `http://` endpoint working with no credential attached. The gateway logs a warning naming the interceptor at every startup, and the service cannot distinguish the gateway from any other client that can reach it. 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 +173,6 @@ 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. +- Extension tokens and sandbox-to-gateway tokens are signed by the same key, separated by audience and `typ`. The extension credential path cannot yet be rotated or revoked independently of sandbox admission. - 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..4c0e18aa57 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,10 @@ 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:`. | +| `allow_insecure_transport` | Opt this registration out of extension authentication, permitting a plaintext `http://` endpoint with no bearer credential. Defaults to `false`. Development and trusted-network deployments only. | | `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 +69,31 @@ 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. + +Return your expected audience in the `expected_audience` field of your `Describe` manifest. OpenShell compares it against the configured value and refuses to start on a mismatch, so a misconfigured audience fails at startup instead of returning an opaque 401 on every request. Leave the field empty to skip the check. + +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. `GET /.well-known/openid-configuration` returns that issuer alongside `jwks_uri`, so a service configured with only the gateway URL can discover both. The document is OIDC-shaped rather than OIDC-compliant: `issuer` is the gateway identity, not the URL serving the document, so compare `iss` against that value and do not infer trust from the document's location. Fetch it over authenticated TLS at the trusted gateway URL, or provision the public key through the deployment when the service cannot reach the gateway. + +Cache keys by `kid`. Validate, at minimum: + +- `typ` is exactly `openshell-ext+jwt`. Extension tokens and sandbox-to-gateway bootstrap tokens share a signing key and differ only in audience; this header is a second, independent discriminator. +- `alg` is pinned to `EdDSA`. Never select the algorithm from the token. +- Signature, expected issuer, exact audience, and positive expiry. +- `caller_kind`, and the sandbox identity when your service scopes behavior per sandbox. + +A sandbox-to-gateway JWT is not an extension credential even though both token types use the same signing key. + +Each token carries a unique `jti`. OpenShell does not track it. If your service needs replay resistance beyond the token lifetime, keep a bounded cache of recently seen `jti` values covering at least the maximum token lifetime of one hour and reject repeats. + +### Run Without Extension Authentication + +Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint working. OpenShell then attaches no credential to that service, supervisors do not request one, and the gateway refuses to mint one if asked. The gateway logs a warning naming the registration at every startup. + +The service cannot distinguish OpenShell from any other client that can reach it. Use this only where the network already provides that guarantee, and prefer `https://` everywhere else. + ## Apply Middleware with Policy Add middleware configs to the top-level `network_middlewares` map. Each key is the policy-local config name: @@ -175,5 +205,7 @@ 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, unless the registration sets `allow_insecure_transport`. Certificates must chain to the configured custom CA or platform roots, and the endpoint hostname must match. +- Extension tokens and sandbox-to-gateway tokens are signed by the same key. They are separated by audience and by `typ`, but the extension credential path cannot yet be rotated or revoked independently of sandbox admission. +- OpenShell does not track `jti`; replay resistance within a token's lifetime is the service's responsibility. +- 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..94cb1dbbdf 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,9 @@ 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://` and TLS `https://`. 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 the service advertises `expected_audience` in its `Describe` manifest, OpenShell refuses to start on a mismatch. + +When `gateway_jwt` is configured, OpenShell attaches short-lived bearer credentials to gateway and supervisor calls and requires `https://`. A middleware endpoint must be reachable from sandbox supervisors, so Unix sockets are not an option here. Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint: OpenShell then attaches no credential, supervisors do not request one, and the gateway logs a warning naming the registration at every startup. 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 +283,19 @@ 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 the interceptor advertises `expected_audience` in its `Describe` manifest, the gateway refuses to start on a mismatch. When `gateway_jwt` is configured, network interceptors must use HTTPS and receive short-lived gateway-caller bearer credentials; local Unix sockets are also supported. Set `allow_insecure_transport = true` to keep a plaintext `http://` interceptor endpoint with no credential attached and a startup warning. + +### Extension Token Verification Endpoints + +When `gateway_jwt` is configured the gateway publishes two unauthenticated documents that extension services use to verify OpenShell callers: + +| Path | Contents | +| --- | --- | +| `/.well-known/jwks.json` | Single-key JWKS holding the Ed25519 public key and its `kid`. | +| `/.well-known/openid-configuration` | OIDC-shaped discovery metadata: the exact expected `iss`, an absolute `jwks_uri`, and `EdDSA` as the only supported signing algorithm. | + +The discovery document is OIDC-shaped rather than OIDC-compliant: `issuer` is the gateway identity (`openshell-gateway:`), not the URL the document is served from. Verifiers compare `iss` against that value and must not infer trust from the document's location; the serving TLS connection is what authenticates the gateway. Both endpoints return `404` when `gateway_jwt` is not configured. + `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/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 5ad8c12266..916dccbad0 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -337,6 +337,7 @@ impl GovernanceInterceptorService { ), ], } + expected_audience: String::new(), } fn evaluate_inner( diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index cf36a4cb0c..9abf29cf70 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -128,6 +128,7 @@ impl SupervisorMiddleware for ContentGuard { max_body_bytes: MAX_BODY_BYTES, timeout: String::new(), }], + expected_audience: String::new(), })) } diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index f6b4a19210..e03a027145 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -100,6 +100,12 @@ message InterceptorManifest { string failure_policy = 3; // True when this interceptor implements SnapshotProviderProfiles. bool provider_profiles = 4; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // When set, the gateway refuses to start unless it matches the + // operator-configured audience, turning a silent runtime 401 into a startup + // failure. Empty means the service does not advertise an audience and the + // check is skipped. + string expected_audience = 5; } message ProviderProfileSnapshot { 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..c3f1c7f44c 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -386,4 +386,16 @@ 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; + // Operator opt-out from extension authentication for this registration. + // When true the service may use a plaintext endpoint and OpenShell attaches + // no bearer credential; supervisors must not request one. Intended only for + // trusted-network development deployments. + bool allow_insecure_transport = 7; } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index dbde411c9f..a522ed68b1 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -34,6 +34,12 @@ message MiddlewareManifest { string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // When set, OpenShell rejects the registration at startup unless it matches + // the operator-configured audience, turning a silent runtime 401 into a + // startup failure. Empty means the service does not advertise an audience + // and the check is skipped. + string expected_audience = 4; } // MiddlewareBinding declares one operation and phase supported by a service. diff --git a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md new file mode 100644 index 0000000000..1d0c31957e --- /dev/null +++ b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md @@ -0,0 +1,64 @@ +# Appendix: Extension Authentication (Alpha) + +> This is an appendix to the [RFC](../README.md). Please familiarize yourself with the RFC before reading this. + +The RFC body left the authenticated-transport mechanism as follow-up protocol work: it required confidentiality plus authentication of the intended middleware service, described a phase 1 `allow_insecure` escape hatch, and deferred the phase 2 choice between mTLS, TLS plus explicit caller authentication, or an equivalent. This appendix records the mechanism that was actually built for alpha. It supersedes the body's transport-authentication and `allow_insecure` paragraphs; the rest of the body is unchanged. + +Related: [protocol-extensions.md](protocol-extensions.md#middleware-authentication). + +## What ships in alpha + +Transport is HTTPS with either platform trust roots or an operator-provided CA bundle, with normal certificate and endpoint-hostname verification. A middleware endpoint must be reachable from every sandbox supervisor as well as the gateway, so a gateway-local Unix socket is not an option for this mechanism. + +Caller identity is a short-lived Ed25519 JWT minted by the gateway's existing sandbox signing authority. The gateway attaches one to its own `Describe` and `ValidateConfig` calls; sandbox supervisors attach one to `Describe` and `EvaluateHttpRequest`. Both directions of the RFC's stated requirement are covered: TLS and the configured trust roots authenticate the middleware service to OpenShell, and the exact-audience JWT proves to the middleware that a gateway or a policy-authorized sandbox supervisor made the call. + +## Claim contract + +| Claim | Meaning | +|---|---| +| `iss` | Exactly `openshell-gateway:`. | +| `aud` | The exact audience for one registration. Never a list. | +| `sub` | Gateway identity for gateway callers; `spiffe://openshell/sandbox/` for supervisor callers. | +| `caller_kind` | `gateway` or `supervisor`. | +| `sandbox_id` | Present only for supervisor callers. | +| `jti` | Unique per token. OpenShell does not track it. | +| `iat`, `exp` | Bounded lifetime, at most one hour. | + +The JOSE header carries `alg: EdDSA`, the signing `kid`, and `typ: openshell-ext+jwt`. + +Explicit typing exists because extension tokens and sandbox-to-gateway admission tokens are signed by the same key and would otherwise be separated by audience alone. A verifier that requires this `typ` cannot accept a sandbox bootstrap credential even if it neglects to check `aud`. This is defense in depth for the most likely verifier mistake, not a replacement for audience validation. + +## Authorization and distribution + +Supervisors request credentials by operator registration name through the existing `RefreshSandboxToken` RPC, which already authenticates a sandbox principal. The gateway resolves each requested name against server-owned registration metadata and the sandbox's effective policy before minting; unknown, unselected, or duplicated names are rejected. Callers never choose an audience. + +Because that resolution runs the full effective-policy composition, two bounds apply. Supervisors rotate only when a credential is missing or has passed four fifths of its lifetime rather than on every configuration poll, and the gateway caps how many minting requests a single sandbox may make per minute. + +## Audience agreement + +The audience is operator configuration on the OpenShell side and service configuration on the middleware side. A mismatch is otherwise invisible until it produces an authentication failure on every evaluated request. + +A service may therefore advertise the audience it verifies in the `expected_audience` field of its `Describe` manifest. OpenShell compares that value against the configured one and rejects the registration at startup on a mismatch. An empty field means the service does not advertise an audience and the check is skipped, so this is additive for existing services. + +## Verification key distribution + +The gateway publishes its public signing key as a single-key JWKS at `/.well-known/jwks.json`, and OIDC-shaped discovery metadata at `/.well-known/openid-configuration` carrying the expected `iss`, an absolute `jwks_uri`, and `EdDSA` as the only supported algorithm. + +The discovery document is OIDC-shaped rather than OIDC-compliant, in the same way and for the same reason as the equivalent Kubernetes endpoint: `issuer` is the gateway identity, not the URL the document is served from. Verifiers compare `iss` against that value. Fetching the document does not establish gateway identity; the serving TLS connection does. Deployments where the service cannot reach the gateway provision the public key out of band instead. + +## Compatibility + +A registration may set `allow_insecure_transport = true`, which replaces the body's `allow_insecure` field with a clearer name and a wider meaning: it opts the registration out of extension authentication entirely. OpenShell attaches no credential, supervisors do not request one, the gateway refuses to mint one if asked, and a warning naming the registration is logged at every gateway startup. + +This keeps plaintext registrations working for trusted local development and isolated research environments, and preserves compatibility for deployments configured before extension authentication existed. The body's intent that this be a temporary, auditable, prominently-warned escape hatch is retained. + +## Residual risks + +- **Bearer replay within the token lifetime.** Anything that obtains a token can use it until expiry. Short bounded lifetimes limit the window; `jti` is present so a service can add a bounded-window replay cache, but OpenShell does not track it. +- **Shared signing key across two trust domains.** Extension credentials and sandbox admission credentials come from one key and one `kid`. They are separated by audience and `typ`, but extension-token issuance cannot be rotated or revoked independently of sandbox admission. A separate extension signing key is the natural next step and the `kid`-based design does not preclude it. +- **Single-key JWKS.** There is no overlap window, so key rotation is not yet a zero-downtime operation. +- **No channel binding.** mTLS or another proof-of-possession mechanism remains deferred hardening. + +## Deferred + +mTLS client authentication, multi-key rotation with an overlap window, replay resistance beyond short expiry, and per-runtime secret delivery are all out of scope for alpha and remain follow-up work. diff --git a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md index 924c7bd699..340ff38dfb 100644 --- a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md +++ b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md @@ -83,4 +83,6 @@ Phase 2 removes plaintext endpoint support and removes `allow_insecure`. Every e The exact phase 2 mechanism is deferred. Follow-up protocol work should choose and specify mTLS, TLS plus explicit caller authentication, or an equivalent design, including trust roots, client identity, credential delivery, certificate or key rotation, middleware identity binding, and how supervisors receive authentication material. +The alpha mechanism that was subsequently built - TLS with optional operator-provided trust roots plus short-lived, exact-audience gateway-signed JWTs - is recorded in [extension-authentication.md](extension-authentication.md). It supersedes this section's `allow_insecure` design with `allow_insecure_transport` and narrows, but does not close, the phase 2 question: mTLS and overlapping key rotation remain deferred. + Even during the phase 1 plaintext exception, the hook stays before provider credential injection, and OpenShell does not forward original `Authorization`, `Cookie`, or other protected headers to middleware. This preserves the separation between content inspection and upstream credential injection while authenticated transport is completed. diff --git a/rfc/0010-gateway-interceptors/appendices/extension-authentication.md b/rfc/0010-gateway-interceptors/appendices/extension-authentication.md new file mode 100644 index 0000000000..0e3d404f2d --- /dev/null +++ b/rfc/0010-gateway-interceptors/appendices/extension-authentication.md @@ -0,0 +1,31 @@ +# Appendix: Extension Authentication (Alpha) + +> This is an appendix to the [RFC](../README.md). Please familiarize yourself with the RFC before reading this. + +The RFC body states that all gateway interceptor connections require authentication, leaves the model out of scope, and suggests implementations should support mTLS and bearer-token authentication. This appendix records the mechanism that was actually built for alpha. It supersedes that paragraph; the rest of the body is unchanged. + +Gateway interceptors and supervisor middleware share one implementation. The full claim contract, authorization model, key distribution, and residual risks are documented once in [RFC 0009's appendix](../../0009-supervisor-middleware/appendices/extension-authentication.md). This appendix records only what differs for interceptors. + +## What ships in alpha + +The bearer-token half of the body's suggestion, not the mTLS half. The gateway attaches a short-lived, exact-audience Ed25519 JWT to `Describe`, `Evaluate`, and provider-profile snapshot calls. mTLS remains deferred hardening. + +Interceptors are called only by the gateway, so every token carries `caller_kind: gateway` and no `sandbox_id`. There is no supervisor-side distribution path and no policy-based authorization step: the gateway mints its own credentials at startup from configuration and rotates them in place. + +## Transport + +Unlike middleware, interceptors keep the body's Unix domain socket option. A gateway-local socket is reachable by the only caller that exists, so both `https://` and `unix://` are accepted when gateway JWT signing is configured. HTTPS endpoints may pin an operator-provided CA bundle and retain normal hostname verification. + +## Audience agreement + +The audience defaults to `urn:openshell:extension:interceptor:` and may be set explicitly per interceptor. An interceptor may advertise the audience it verifies in the `expected_audience` field of its `Describe` manifest; the gateway compares it against the configured value and refuses to start on a mismatch, turning a silent runtime authentication failure into a startup failure. + +Because the body already makes an unavailable service, invalid manifest, or unauthorized binding a startup failure, this fits the existing posture: interceptor configuration problems surface before the gateway serves traffic. + +## Compatibility + +An interceptor may set `allow_insecure_transport = true` to keep a plaintext `http://` endpoint with no credential attached. The gateway logs a warning naming the interceptor at every startup. This exists for local development and for deployments configured before extension authentication existed. + +## Deferred + +mTLS client authentication, multi-key rotation with an overlap window, and replay resistance beyond short expiry remain follow-up work, as recorded in RFC 0009's appendix. 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..8d23793a39 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -1866,9 +1866,21 @@ 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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"` + // Operator opt-out from extension authentication for this registration. + // When true the service may use a plaintext endpoint and OpenShell attaches + // no bearer credential; supervisors must not request one. Intended only for + // trusted-network development deployments. + AllowInsecureTransport bool `protobuf:"varint,7,opt,name=allow_insecure_transport,json=allowInsecureTransport,proto3" json:"allow_insecure_transport,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SupervisorMiddlewareService) Reset() { @@ -1929,6 +1941,27 @@ 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 "" +} + +func (x *SupervisorMiddlewareService) GetAllowInsecureTransport() bool { + if x != nil { + return x.AllowInsecureTransport + } + return false +} + var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + @@ -2094,12 +2127,15 @@ 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\"\x93\x02\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\x128\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" +