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