From a5b08ad016af49b8fd5640494be7d00662427764 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 15:36:46 -0700 Subject: [PATCH 1/8] feat(gateway): broker stable sandbox SPIFFE provider tokens Signed-off-by: Drew Newberry --- .agents/skills/helm-dev-environment/SKILL.md | 8 + Cargo.lock | 1 + .../tests/ensure_providers_integration.rs | 8 + .../openshell-cli/tests/mtls_integration.rs | 8 + .../tests/provider_commands_integration.rs | 8 + .../sandbox_create_lifecycle_integration.rs | 8 + .../sandbox_exec_streaming_integration.rs | 7 + .../sandbox_name_fallback_integration.rs | 8 + crates/openshell-core/src/config.rs | 36 + crates/openshell-core/src/grpc_client.rs | 40 + crates/openshell-core/src/oauth.rs | 25 + crates/openshell-core/src/spiffe.rs | 94 + crates/openshell-driver-kubernetes/README.md | 12 +- .../openshell-driver-kubernetes/src/config.rs | 3 - crates/openshell-sdk/tests/client_mock.rs | 7 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/README.md | 19 + crates/openshell-server/src/cli.rs | 10 + crates/openshell-server/src/config_file.rs | 3 + crates/openshell-server/src/grpc/mod.rs | 8 + crates/openshell-server/src/grpc/provider.rs | 27 +- .../src/grpc/provider/access_token.rs | 356 +++ crates/openshell-server/src/lib.rs | 31 +- .../openshell-server/src/spiffe_delegation.rs | 373 ++++ crates/openshell-server/src/storage_proto.rs | 10 +- crates/openshell-server/tests/common/mod.rs | 8 + .../tests/supervisor_relay_integration.rs | 8 + crates/openshell-supervisor-network/README.md | 24 +- .../src/l7/token_grant_injection.rs | 36 - .../openshell-supervisor-network/src/run.rs | 35 +- .../src/token_grant.rs | 172 +- crates/openshell-supervisor/README.md | 16 +- .../src/backend_setup/tests.rs | 2 +- .../src/legacy_startup_regression_tests.rs | 2 +- crates/openshell-supervisor/src/lib.rs | 10 +- crates/openshell-supervisor/src/main.rs | 13 +- .../src/sandbox_instance_host.rs | 4 +- .../openshell-supervisor/src/shared_host.rs | 10 +- .../ci/values-spire-delegation-stack.yaml | 33 + .../ci/values-supervisor-spiffe.yaml | 15 + .../openshell/templates/_gateway-workload.tpl | 18 +- deploy/helm/openshell/templates/_helpers.tpl | 31 +- .../helm/openshell/tests/supervisor_test.yaml | 2 +- .../tests/supervisor_wiring_test.yaml | 46 + e2e/rust/Cargo.toml | 2 + .../kubernetes_shared_supervisor_spiffe.rs | 877 ++++++++ e2e/with-kube-gateway.sh | 68 + proto/openshell.proto | 20 + sdk/go/proto/openshellv1/openshell.pb.go | 1935 +++++++++-------- sdk/go/proto/openshellv1/openshell_grpc.pb.go | 42 + tasks/scripts/test-spire-delegation.sh | 187 ++ tasks/test.toml | 10 + 52 files changed, 3657 insertions(+), 1080 deletions(-) create mode 100644 crates/openshell-server/src/grpc/provider/access_token.rs create mode 100644 crates/openshell-server/src/spiffe_delegation.rs create mode 100644 deploy/helm/openshell/ci/values-spire-delegation-stack.yaml create mode 100644 deploy/helm/openshell/ci/values-supervisor-spiffe.yaml create mode 100644 e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs create mode 100755 tasks/scripts/test-spire-delegation.sh diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 0b5a069eeb..e5c1e6b1b3 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -96,6 +96,14 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \ mise run e2e:kubernetes:shared-supervisor ``` +`mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts +(`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the +`openshell.gateway.experimental_spiffe_delegation` overlay +(`ci/values-supervisor-spiffe.yaml`), then registers sandbox +identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation` +checks delegated issuance and identity selection against a local SPIRE server +and agent without a cluster; the Kubernetes test covers the gateway broker. + ### 2. Deploy OpenShell **Iterative dev** (rebuilds on file changes, recommended during active development): diff --git a/Cargo.lock b/Cargo.lock index 9ed9b67d66..76e7e0d44d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5066,6 +5066,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "toml", "tonic", + "tonic-prost", "tonic-prost-build", "tonic-reflection", "tower", diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 53ccb70309..b2c7289755 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -80,6 +80,14 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 2fd266941a..55d963584d 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -32,6 +32,14 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 8812daeefe..96965dc7a3 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -235,6 +235,14 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 53f4ef3387..bf48acb22c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -101,6 +101,14 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs index 48b84a3373..d894a466be 100644 --- a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs +++ b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs @@ -163,6 +163,13 @@ macro_rules! mock_gateway { ) => { #[tonic::async_trait] impl OpenShell for MockGateway { + async fn get_sandbox_provider_access_token( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + $(async fn $method(&self, _: Request<$request>) -> Result, Status> { Err(Status::unimplemented("unused test RPC")) })* diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 46f7c31306..1c05460ca2 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -47,6 +47,14 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 7f5d4d1ec0..cc66bc2a0a 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -205,6 +205,39 @@ pub fn normalize_compute_driver_name(value: &str) -> Result { Ok(value.to_ascii_lowercase()) } +/// Experimental gateway-owned SPIRE delegation for sandbox provider grants. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExperimentalSpiffeDelegationConfig { + pub admin_socket_path: String, + pub spiffe_id_prefix: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admin_socket_group: Option, +} + +impl ExperimentalSpiffeDelegationConfig { + /// Reject ambiguous host paths and identities before opening the SPIRE socket. + pub fn validate(&self, gateway_id: &str) -> Result<(), String> { + let path = std::path::Path::new(&self.admin_socket_path); + if !path.is_absolute() + || path + .parent() + .is_none_or(|parent| parent == std::path::Path::new("/")) + || self + .admin_socket_path + .split('/') + .skip(1) + .any(|part| part.is_empty() || part == "." || part == "..") + || self.admin_socket_group == Some(0) + { + return Err("experimental_spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into()); + } + crate::spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)? + .sandbox_spiffe_id(gateway_id, "sandbox")?; + Ok(()) + } +} + /// Server configuration. /// /// Built programmatically in [`crate::Config::new`] and the gateway CLI from @@ -269,6 +302,8 @@ pub struct Config { /// JWTs as `Principal::Sandbox`. Required for the per-sandbox identity /// flow (issue #1354). pub gateway_jwt: Option, + /// Gateway-only access to SPIRE; shared supervisors receive access tokens. + pub experimental_spiffe_delegation: Option, /// Database URL for persistence. pub database_url: String, @@ -940,6 +975,7 @@ impl Config { provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User], mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, + experimental_spiffe_delegation: None, database_url: String::new(), database_max_connections: None, database_lock_max_connections: None, diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 3a13ed88e5..132d3568c8 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1578,6 +1578,46 @@ pub async fn exchange_provider_subject_token( )) } +/// Obtain only the final access token; the gateway selects and verifies the +/// sandbox identity and resolves all grant parameters from its configuration. +#[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)] +pub async fn get_sandbox_provider_access_token( + gateway: &GatewayEndpoint, + credential_key: &str, + request_id: Option<&str>, +) -> std::result::Result { + let endpoint = token_grant_gateway_endpoint(gateway.url())?; + let mut client = connect(&gateway.with_url(endpoint.as_str())) + .await + .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?; + let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest { + credential_key: credential_key.to_string(), + }); + if let Some(value) = request_id.and_then(|value| value.parse().ok()) { + request.metadata_mut().insert("x-request-id", value); + } + let response = tokio::time::timeout( + Duration::from_mins(1), + client.get_sandbox_provider_access_token(request), + ) + .await + .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))? + .map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))? + .into_inner(); + // connect() and the response boundary both reject locally revoked sandbox + // instance credentials. + if gateway.credentials().is_revoked() { + return Err(gateway_grant_failure( + TokenGrantFailureKind::WorkloadIdentityRejected, + )); + } + provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse { + access_token: response.access_token, + expires_after: response.expires_after, + token_type: response.token_type, + }) +} + /// Attach the exchange's request ID as `x-request-id` gRPC metadata. /// /// A value that is not valid metadata is omitted, never rewritten. diff --git a/crates/openshell-core/src/oauth.rs b/crates/openshell-core/src/oauth.rs index dcf56cf5c6..58ae2fa4dd 100644 --- a/crates/openshell-core/src/oauth.rs +++ b/crates/openshell-core/src/oauth.rs @@ -24,6 +24,9 @@ pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_tok const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; const ERROR_CONTRACT: &str = "openshell.token-grant.v1"; const MAX_TOKEN_RESPONSE_BYTES: usize = 64 * 1024; +pub const DEFAULT_TOKEN_CACHE_TTL_SECONDS: i64 = 300; +pub const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; +pub const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600; /// HTTP client that authenticates HTTPS endpoints and never redirects assertions. /// @@ -263,6 +266,28 @@ pub fn effective_token_type(token_type: &str) -> &str { } } +/// Cache lifetime for a granted access token. +/// +/// A configured override is used exactly, including zero to disable caching. +/// Otherwise the response lifetime (capped, or a default when omitted) is +/// shortened by a fixed expiry skew, with a one-second floor. +pub fn token_cache_ttl(cache_ttl_override: Option, expires_in: i64) -> Duration { + if let Some(cache_ttl_override) = cache_ttl_override { + return cache_ttl_override; + } + + let ttl = if expires_in > 0 { + expires_in.min(MAX_TOKEN_EXPIRES_IN_SECONDS) + } else { + DEFAULT_TOKEN_CACHE_TTL_SECONDS + }; + + Duration::from_secs( + u64::try_from(ttl.saturating_sub(TOKEN_CACHE_EXPIRY_SKEW_SECONDS).max(1)) + .unwrap_or(u64::MAX), + ) +} + /// Validate the configured endpoint before acquiring any workload credentials. /// /// HTTPS is accepted; plain HTTP is limited to loopback and Kubernetes service diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-core/src/spiffe.rs index c288dfd81a..6b307587a9 100644 --- a/crates/openshell-core/src/spiffe.rs +++ b/crates/openshell-core/src/spiffe.rs @@ -79,6 +79,77 @@ pub fn trust_domain(subject: &str) -> Option<&str> { (!trust_domain.is_empty()).then_some(trust_domain) } +/// Longest SPIFFE ID the SPIFFE specification permits. +const MAX_SPIFFE_ID_BYTES: usize = 2048; + +/// Operator-configured prefix of the stable SPIFFE IDs the gateway requests +/// for sandboxes using shared supervision. +/// +/// A sandbox's ID is `/gateway//sandbox/`. +/// Both IDs must already be valid SPIFFE path segments; nothing is encoded, +/// so distinct gateway and sandbox IDs always produce distinct SPIFFE IDs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SandboxSpiffeIdPrefix(String); + +impl SandboxSpiffeIdPrefix { + /// Accept `spiffe://` optionally followed by path segments, + /// with no trailing slash, query, fragment, or percent-encoding. + pub fn parse(value: &str) -> Result { + let invalid = || { + format!( + "SPIFFE ID prefix must be spiffe://[/...] with lowercase trust domain characters [a-z0-9.-_] and path segment characters [A-Za-z0-9.-_]: {value:?}" + ) + }; + let rest = value.strip_prefix("spiffe://").ok_or_else(invalid)?; + let (domain, path) = rest + .split_once('/') + .map_or((rest, None), |(domain, path)| (domain, Some(path))); + if domain.is_empty() + || !domain.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-_".contains(&byte) + }) + { + return Err(invalid()); + } + if let Some(path) = path + && !path.split('/').all(is_spiffe_path_segment) + { + return Err(invalid()); + } + Ok(Self(value.to_string())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Stable SPIFFE ID of `sandbox_id` under `gateway_id`. + pub fn sandbox_spiffe_id(&self, gateway_id: &str, sandbox_id: &str) -> Result { + if !is_spiffe_path_segment(gateway_id) || !is_spiffe_path_segment(sandbox_id) { + return Err( + "gateway and sandbox IDs must be SPIFFE path segments [A-Za-z0-9.-_]".to_string(), + ); + } + let id = format!("{}/gateway/{gateway_id}/sandbox/{sandbox_id}", self.0); + if id.len() > MAX_SPIFFE_ID_BYTES { + return Err("sandbox SPIFFE ID exceeds 2048 bytes".to_string()); + } + Ok(id) + } +} + +/// A non-empty SPIFFE path segment other than `.` and `..`. +#[must_use] +pub fn is_spiffe_path_segment(segment: &str) -> bool { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte)) +} + #[cfg(test)] mod tests { use super::*; @@ -164,6 +235,29 @@ mod tests { )); } + #[test] + fn sandbox_spiffe_ids_are_unambiguous_and_reject_unsafe_input() { + let prefix = SandboxSpiffeIdPrefix::parse("spiffe://example.org/openshell").unwrap(); + assert_eq!( + prefix.sandbox_spiffe_id("gw", "4f1c2b3a-9d8e").unwrap(), + "spiffe://example.org/openshell/gateway/gw/sandbox/4f1c2b3a-9d8e" + ); + for invalid in [ + "spiffe://Example.org", + "spiffe://example.org/", + "spiffe://example.org//a", + "spiffe://example.org/a/../b", + "spiffe://example.org/a%2Fb", + "spiffe://example.org:8443/a", + "https://example.org/a", + ] { + assert!(SandboxSpiffeIdPrefix::parse(invalid).is_err(), "{invalid}"); + } + for (gateway, sandbox) in [("gw/x", "a"), ("gw", "a/b"), ("gw", ".."), ("", "a")] { + assert!(prefix.sandbox_spiffe_id(gateway, sandbox).is_err()); + } + } + #[test] fn trust_domain_extracts_domain_from_spiffe_id() { assert_eq!( diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 7dc2478d34..55b1476fad 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -254,9 +254,15 @@ fails closed. when the token that opened it expires. Sandboxes cannot reach the stream, and the routing preface only selects which generation-pinned TLS identity the supervisor demands. - -The topology does not support upstream proxies or provider SPIFFE identity, and -the shared supervisor is a single point of failure for the sandboxes it hosts. +- **Workload identity.** The gateway owns SPIRE delegation and provider-token + exchange when `openshell.gateway.experimental_spiffe_delegation` is configured. + Shared supervisors receive final access tokens through their authenticated + sessions. No SPIFFE socket is mounted in a shared supervisor or workload Pod. + The existing Workload API option supplies the gateway's component identity + for the RFC 8693 intermediate exchange. + +The topology does not support upstream proxies, and the shared supervisor is a +single point of failure for the sandboxes it hosts. Shared sandbox instances export no supervisor OTLP traces or OCSF events; the driver warns at startup when the gateway configures that export. When the setting is removed, reconciliation suspends shared generations, and a diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 598e0b20ce..e496c9fe4c 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -552,9 +552,6 @@ impl KubernetesComputeConfig { "supervisor_topology=gateway requires shared_supervisor".to_string() })?; shared.validate()?; - if self.provider_spiffe_enabled() { - return Err("supervisor_topology=gateway does not support provider_spiffe_workload_api_socket_path; a shared supervisor has no per-sandbox workload identity".into()); - } if self.https_proxy.is_some() || self.proxy_auth_secret_name.is_some() || self.proxy_ca_bundle.is_some() diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 56ea582109..6ccb747cbe 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -234,6 +234,13 @@ fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloa #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 9160719bf9..a48f89b717 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -43,6 +43,7 @@ libc = "0.2" tonic = { workspace = true, features = ["channel", "tls-native-roots"] } tonic-reflection = { workspace = true } prost = { workspace = true } +tonic-prost = { workspace = true } prost-reflect = { workspace = true } prost-types = { workspace = true } diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 58f735c35c..897db62125 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -84,3 +84,22 @@ runs under `mise run test:rust:postgres` (see `TESTING.md`) and still accepts `OPENSHELL_REFRESH_TEST_DATABASE_URL` pointing to a disposable PostgreSQL database. It exercises independent sessions without the local mutex. Do not point this fixture at an existing deployment database. + +## Experimental sandbox identity broker + +`openshell.gateway.experimental_spiffe_delegation` enables gateway-owned SPIRE +acquisition for the Kubernetes shared supervisor. The private admin +socket is mounted only in the gateway. `GetSandboxProviderAccessToken` accepts an +endpoint-bound configured credential key; sandbox identity comes from the +verified, currently authorized session JWT. Provider attachment and profile +configuration determine the endpoint, audience, and scopes. The gateway performs +RFC 7523 or both RFC 8693 exchanges and returns only the final access token. +Its bounded cache is isolated by sandbox generation, credential epoch, provider +revision, and grant. The session is authorized again around each SPIRE and +token-service call. A cache hit and a fresh token then pass the same final +check: the grant configuration still matches and the session is still +authorized. A fresh token is cached only after that check. Startup validates +the delegation settings, and gateway state construction parses them once and +returns a configuration error when they are invalid; each request still fetches +a new SVID and the agent's current JWT bundle. SPIRE still trusts the gateway as a +delegate for its agent's available registration scope. diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 5b2ce01c13..2589ede441 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -649,6 +649,16 @@ fn prepare_server_config_with_drivers( // the generated TLS bundle so upgrades pick up sandbox auth without a // user-authored config file. config.gateway_jwt = gateway_jwt; + config.experimental_spiffe_delegation = file.as_ref().and_then(|file| { + file.openshell + .gateway + .experimental_spiffe_delegation + .clone() + }); + + // Reject invalid delegation before startup; gateway state parses it later. + crate::spiffe_delegation::SpiffeDelegation::validate_config(&config) + .map_err(|error| miette::miette!("{error}"))?; Ok(ServerStartupConfig { config, diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 817a6a02ad..3a260839da 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -189,6 +189,9 @@ pub struct GatewayFileSection { #[serde(default)] pub gateway_jwt: Option, #[serde(default)] + pub experimental_spiffe_delegation: + Option, + #[serde(default)] pub otlp: Option, #[serde(default)] pub ocsf_log: Option, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 0368025348..503b3d72ea 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -647,6 +647,14 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + async fn get_sandbox_provider_access_token( + &self, + request: Request, + ) -> Result, Status> + { + provider::access_token::handle(&self.state, request).await + } + async fn exchange_provider_subject_token( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index b77b9ed45b..9d08d758b1 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5,6 +5,8 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> +pub(super) mod access_token; + use crate::compute::MutationScope; #[cfg(test)] use crate::credentials::RefreshMaterialScope; @@ -8675,17 +8677,20 @@ mod tests { // Model a second gateway replica: it shares the durable database and // credential backend, but owns an independent sandbox synchronization // guard, so process-local serialization cannot hide this race. - let second_state = Arc::new(ServerState::new_with_credentials( - first_state.config.clone(), - Arc::clone(&first_state.store), - crate::compute::new_test_runtime(Arc::clone(&first_state.store)).await, - crate::sandbox_index::SandboxIndex::new(), - crate::sandbox_watch::SandboxWatchBus::new(), - crate::tracing_bus::TracingLogBus::new(), - Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), - None, - first_state.credentials.clone(), - )); + let second_state = Arc::new( + ServerState::new_with_credentials( + first_state.config.clone(), + Arc::clone(&first_state.store), + crate::compute::new_test_runtime(Arc::clone(&first_state.store)).await, + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + None, + first_state.credentials.clone(), + ) + .unwrap(), + ); let request = |client_secret: &str| ConfigureProviderRefreshRequest { request_id: String::new(), provider: "configure-create-race".to_string(), diff --git a/crates/openshell-server/src/grpc/provider/access_token.rs b/crates/openshell-server/src/grpc/provider/access_token.rs new file mode 100644 index 0000000000..15cc8829c1 --- /dev/null +++ b/crates/openshell-server/src/grpc/provider/access_token.rs @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-owned sandbox identity and provider-token acquisition. +//! The caller selects an attached configured credential, never grant parameters. +use super::{ + Arc, HashMap, IntermediateTokenCache, LazyLock, Message, ObjectId, ObjectWorkspace, Principal, + Provider, ProviderCredentialTokenGrantType, Request, Response, Sandbox, ServerState, Status, + TokenExchangeParams, TokenGrantFailure, canonical_request_id, effective_jwt_svid_audience, + fetch_gateway_jwt_svid, get_provider_type_profile_for_scope, + insert_dynamic_credentials_for_profile, log_token_grant_failure, oauth, + perform_intermediate_token_exchange, provider_credential_expiration_ms, + resolve_subject_token_credential, sanitize_token_grant_status, token_exchange_http_client, + token_grant_configuration_status, token_grant_dependency_status, token_grant_identity_rejected, + token_grant_store_status, +}; +use openshell_core::jwt::AuthenticatedSandboxSession; +use openshell_core::proto::{ + GetSandboxProviderAccessTokenRequest, GetSandboxProviderAccessTokenResponse, + ProviderCredentialTokenGrant, +}; + +static ACCESS_TOKENS: LazyLock = LazyLock::new(IntermediateTokenCache::new); + +pub(in crate::grpc) async fn handle( + state: &Arc, + request: Request, +) -> Result, Status> { + let request_id = canonical_request_id(request.metadata()); + let result = obtain(state, request, request_id.as_deref()).await; + result.map_err(|status| { + let status = sanitize_token_grant_status(status); + log_token_grant_failure( + None, + request_id.as_deref(), + TokenGrantFailure::from_status(&status), + ); + status + }) +} + +/// Verify the original bearer again after asynchronous work, including expiry +/// and the persisted generation, epoch, active phase, and current token lineage. +async fn authorize( + state: &ServerState, + bearer: &str, +) -> Result { + let authority = state + .sandbox_session_jwt_authority + .as_ref() + .ok_or_else(|| token_grant_identity_rejected(tonic::Code::Unauthenticated))?; + let session = authority + .verify_gateway_token(bearer) + .map_err(|error| error.status)?; + if session.expires_at == 0 { + return Err(token_grant_identity_rejected(tonic::Code::Unauthenticated)); + } + crate::auth::sandbox_session::authorize_persisted(&state.store, &session) + .await + .map_err(|error| error.status)?; + Ok(session) +} + +/// Resolve the exact endpoint selector from the gateway's approved profile. +/// Audience and scopes (including endpoint overrides) never come from the caller. +async fn grant_configuration( + state: &ServerState, + sandbox_id: &str, + key: &str, +) -> Result<(Provider, ProviderCredentialTokenGrant), Status> { + let invalid = || token_grant_configuration_status(tonic::Code::PermissionDenied); + let identity = + openshell_core::dynamic_credential_key::credential_identity(key).ok_or_else(invalid)?; + let (provider_name, _) = identity.split_once(':').ok_or_else(invalid)?; + let selector = + openshell_core::dynamic_credential_key::endpoint_selector(key).ok_or_else(invalid)?; + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|_| token_grant_store_status())? + .ok_or_else(invalid)?; + let spec = sandbox.spec.as_ref().ok_or_else(invalid)?; + if !spec.providers.iter().any(|name| name == provider_name) { + return Err(invalid()); + } + let provider = state + .store + .get_message_by_name::(sandbox.object_workspace(), provider_name) + .await + .map_err(|_| token_grant_store_status())? + .ok_or_else(invalid)?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(&state.store, sandbox.object_workspace()) + .await + .map_err(token_grant_dependency_status)?; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(invalid)?; + let mut configured = HashMap::new(); + insert_dynamic_credentials_for_profile( + &mut configured, + &profile, + provider_name, + provider.object_id(), + )?; + let mut matching = configured.iter().filter(|(candidate, _)| { + let Some(endpoint) = openshell_core::dynamic_credential_key::endpoint_selector(candidate) + else { + return false; + }; + endpoint.host == selector.host + && endpoint.port == selector.port + && endpoint.path == selector.path + && openshell_core::dynamic_credential_key::credential_identity(candidate) + == Some(identity) + }); + let (_, credential) = matching.next().ok_or_else(invalid)?; + if matching.next().is_some() { + return Err(invalid()); + } + let grant = credential.token_grant.clone().ok_or_else(invalid)?; + oauth::parse_token_endpoint_url(&grant.token_endpoint).map_err(TokenGrantFailure::to_status)?; + Ok((provider, grant)) +} + +fn cache_key( + session: &AuthenticatedSandboxSession, + provider: &Provider, + grant: &ProviderCredentialTokenGrant, + credential_key: &str, + gateway_id: &str, +) -> Result { + use sha2::{Digest as _, Sha256}; + let binding = serde_json::to_vec(&( + gateway_id, + session.sandbox_id.as_str(), + session.runtime_generation.as_str(), + session.auth_epoch.get(), + provider.object_id(), + provider + .metadata + .as_ref() + .map(|metadata| metadata.resource_version), + credential_key, + Message::encode_to_vec(grant), + )) + .map_err(|_| token_grant_configuration_status(tonic::Code::Internal))?; + Ok(hex::encode(Sha256::digest(binding))) +} + +#[allow(clippy::too_many_lines)] +async fn obtain( + state: &Arc, + request: Request, + request_id: Option<&str>, +) -> Result, Status> { + let Some(Principal::Sandbox(principal)) = request.extensions().get::() else { + return Err(token_grant_identity_rejected(tonic::Code::PermissionDenied)); + }; + let mut values = request.metadata().get_all("authorization").iter(); + let bearer = values + .next() + .and_then(|value| value.to_str().ok()) + .and_then(openshell_core::auth::strip_bearer_scheme) + .ok_or_else(|| token_grant_identity_rejected(tonic::Code::Unauthenticated))? + .to_string(); + if values.next().is_some() { + return Err(token_grant_identity_rejected(tonic::Code::Unauthenticated)); + } + let session = authorize(state, &bearer).await?; + if principal.sandbox_id != session.sandbox_id.as_str() { + return Err(token_grant_identity_rejected(tonic::Code::PermissionDenied)); + } + let delegation = state + .spiffe_delegation + .as_ref() + .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + if state.compute.configured_driver_name() != "kubernetes" { + return Err(token_grant_configuration_status( + tonic::Code::FailedPrecondition, + )); + } + let gateway_id = delegation.gateway_id(); + let key = &request.get_ref().credential_key; + let (provider, grant) = grant_configuration(state, session.sandbox_id.as_str(), key).await?; + let fingerprint = cache_key(&session, &provider, &grant, key, gateway_id)?; + let ttl_override = grant + .cache_ttl + .as_ref() + .map(openshell_core::time::duration_to_std) + .transpose() + .map_err(|_| token_grant_configuration_status(tonic::Code::InvalidArgument))?; + let grant_type = ProviderCredentialTokenGrantType::try_from(grant.grant_type) + .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + let mut subject_expiry = None; + let subject = if grant_type == ProviderCredentialTokenGrantType::TokenExchange { + let subject = grant + .subject_token + .as_ref() + .filter(|subject| subject.source == "provider_credential") + .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + subject_expiry = provider_credential_expiration_ms(&provider, &subject.credential)?; + Some( + resolve_subject_token_credential(&state.credentials, &provider, &subject.credential) + .await?, + ) + } else { + None + }; + let cached = if ttl_override == Some(std::time::Duration::ZERO) { + None + } else { + ACCESS_TOKENS.get(&fingerprint) + }; + // A miss also yields when its token may be cached; it is inserted only + // after the final validation below, which a hit passes as well. + let (response, cache_until) = if let Some(cached) = cached { + (cached, None) + } else { + authorize(state, &bearer).await?; + let audience = effective_jwt_svid_audience(&grant.token_endpoint, &grant.jwt_svid_audience); + let (svid, svid_expiry) = delegation + .fetch_jwt_svid(session.sandbox_id.as_str(), &audience) + .await + .map_err(TokenGrantFailure::to_status)?; + authorize(state, &bearer).await?; + let client = token_exchange_http_client()?; + let token = if let Some(subject) = subject { + let component_svid = fetch_gateway_jwt_svid(&audience).await?; + let stable_id = delegation + .sandbox_spiffe_id(session.sandbox_id.as_str()) + .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + let intermediate = perform_intermediate_token_exchange( + &grant.token_endpoint, + &TokenExchangeParams { + client_assertion: &component_svid, + client_assertion_type: &grant.client_assertion_type, + subject_token: &subject, + subject_token_type: &grant + .subject_token + .as_ref() + .ok_or_else(|| { + token_grant_configuration_status(tonic::Code::FailedPrecondition) + })? + .subject_token_type, + audience: &stable_id, + scopes: &[], + requested_token_type: &grant.requested_token_type, + }, + request_id, + ) + .await?; + authorize(state, &bearer).await?; + oauth::post_oauth_token_exchange( + client, + &grant.token_endpoint, + &TokenExchangeParams { + client_assertion: &svid, + client_assertion_type: &grant.client_assertion_type, + subject_token: &intermediate.access_token, + subject_token_type: oauth::ACCESS_TOKEN_TYPE, + audience: &grant.audience, + scopes: &grant.scopes, + requested_token_type: &grant.requested_token_type, + }, + request_id, + ) + .await + .map_err(TokenGrantFailure::to_status)? + } else { + oauth::post_oauth_token_grant( + client, + &grant.token_endpoint, + &oauth::TokenGrantParams { + client_assertion: &svid, + client_assertion_type: &grant.client_assertion_type, + audience: &grant.audience, + scopes: &grant.scopes, + }, + request_id, + ) + .await + .map_err(TokenGrantFailure::to_status)? + }; + authorize(state, &bearer).await?; + let cache_ttl = oauth::token_cache_ttl(ttl_override, token.expires_in); + let now = crate::persistence::current_time_ms(); + // Stop serving the token a second before the session, SVID, or subject expires. + let identity_expiry = session + .expires_at + .saturating_mul(1000) + .min(svid_expiry.saturating_mul(1000)) + .min(subject_expiry.unwrap_or(i64::MAX)) + .saturating_sub(1000); + let expires_at = now + .saturating_add(i64::try_from(cache_ttl.as_millis()).unwrap_or(i64::MAX)) + .min(identity_expiry); + (token, (expires_at > now).then_some(expires_at)) + }; + // Re-read attachment, provider revision, and profile, then the session, + // before serving or caching any token. + let (current_provider, current_grant) = + grant_configuration(state, session.sandbox_id.as_str(), key).await?; + if cache_key(&session, ¤t_provider, ¤t_grant, key, gateway_id)? != fingerprint { + return Err(token_grant_configuration_status( + tonic::Code::FailedPrecondition, + )); + } + authorize(state, &bearer).await?; + if let Some(expires_at) = cache_until { + ACCESS_TOKENS.set(fingerprint, &response, expires_at); + } + Ok(Response::new(GetSandboxProviderAccessTokenResponse { + access_token: response.access_token, + token_type: response.token_type, + expires_after: openshell_core::time::duration_from_std(std::time::Duration::from_secs( + u64::try_from(response.expires_in).unwrap_or_default(), + )) + .ok(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal}; + #[tokio::test] + async fn broker_requires_a_signed_session_even_with_a_sandbox_principal() { + let state = crate::grpc::test_support::test_server_state().await; + for principal in [ + Principal::Anonymous, + Principal::Sandbox(SandboxPrincipal { + sandbox_id: uuid::Uuid::new_v4().to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "launch-session".into(), + }, + trust_domain: Some("openshell".into()), + }), + ] { + let mut request = Request::new(GetSandboxProviderAccessTokenRequest { + credential_key: "untrusted".into(), + }); + request.extensions_mut().insert(principal); + let error = handle(&state, request).await.unwrap_err(); + assert!(matches!( + error.code(), + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied + )); + } + } +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index f1f991e4ae..79d45b8dcd 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -43,6 +43,7 @@ mod reflection; mod sandbox_index; mod sandbox_watch; mod service_routing; +mod spiffe_delegation; mod ssh_identity; mod ssh_sessions; mod storage_proto; @@ -414,6 +415,9 @@ pub struct ServerState { /// on demand when the user source is configured. pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, + /// Parsed gateway-owned SPIRE delegation settings. `None` when disabled. + pub(crate) spiffe_delegation: Option, + /// OIDC admin role name for workspace-level authorization. /// Empty when OIDC is not configured — `authorize_workspace()` treats /// every authenticated user as Platform Admin in that case. @@ -433,6 +437,12 @@ fn is_benign_connection_close(error: &(dyn std::error::Error + 'static)) -> bool impl ServerState { /// Create new server state. + /// + /// # Panics + /// + /// Panics when `config` holds credential-driver or SPIRE delegation + /// settings that startup validation would reject. Gateway startup uses + /// [`Self::new_with_credentials`], which reports those as errors. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( @@ -459,10 +469,18 @@ impl ServerState { oidc_cache, credentials, ) + .expect("server config should be validated before ServerState::new") } /// Create new server state with an already-initialized credential runtime. - #[must_use] + /// + /// Parses the SPIRE delegation settings into the state that serves every + /// provider access-token request. + /// + /// # Errors + /// + /// Returns a configuration error when the SPIRE delegation settings are + /// invalid. #[allow(clippy::too_many_arguments)] pub fn new_with_credentials( config: Config, @@ -474,7 +492,9 @@ impl ServerState { supervisor_sessions: Arc, oidc_cache: Option>, credentials: credentials::CredentialRuntime, - ) -> Self { + ) -> Result { + let spiffe_delegation = + spiffe_delegation::SpiffeDelegation::from_config(&config).map_err(Error::config)?; let replica_id = compute::lease::replica_id(); compute.configure_ssh_identities(credentials.clone()); let peer_endpoint = derive_peer_endpoint(&config); @@ -487,7 +507,7 @@ impl ServerState { .map_or_else(String::new, |oidc| oidc.admin_role.clone()); let config_delivery = config_delivery::ConfigDelivery::for_db_connections(store.max_connections()); - Self { + Ok(Self { config, store, compute, @@ -520,8 +540,9 @@ impl ServerState { gateway_interceptors: None, provider_profile_sources: provider_profile_sources::ProviderProfileSources::with_default_sources(), + spiffe_delegation, admin_role, - } + }) } } @@ -782,7 +803,7 @@ pub(crate) async fn run_server( supervisor_sessions, oidc_cache, credentials, - ); + )?; state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; diff --git a/crates/openshell-server/src/spiffe_delegation.rs b/crates/openshell-server/src/spiffe_delegation.rs new file mode 100644 index 0000000000..f7766d592d --- /dev/null +++ b/crates/openshell-server/src/spiffe_delegation.rs @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SPIRE delegated issuance owned by the gateway, never by a supervisor or sandbox. +use openshell_core::config::{Config, ExperimentalSpiffeDelegationConfig}; +use openshell_core::spiffe::SandboxSpiffeIdPrefix; +use openshell_core::token_grant_failure::{ + TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +const AGENT_CALL_TIMEOUT: Duration = Duration::from_secs(10); + +/// Validated, immutable settings of gateway-owned SPIRE delegation. +/// +/// Gateway state parses these once when it is constructed. Requests reuse the parsed +/// socket, sandbox SPIFFE ID prefix, trust domain, and gateway identity, but +/// fetch each SVID and the agent's current JWT bundle afresh. +#[derive(Debug)] +pub struct SpiffeDelegation { + admin_socket: PathBuf, + prefix: SandboxSpiffeIdPrefix, + trust_domain: spiffe::TrustDomain, + gateway_id: String, +} + +impl SpiffeDelegation { + /// Check the delegation settings `config` enables without building them. + /// + /// # Errors + /// + /// Rejects delegation without gateway session JWTs and a positive session + /// TTL, and settings [`ExperimentalSpiffeDelegationConfig::validate`] + /// rejects. + pub fn validate_config(config: &Config) -> Result<(), String> { + enabled(config).map(drop) + } + + /// The delegation `config` enables, if any. + /// + /// # Errors + /// + /// Rejects what [`Self::validate_config`] rejects. + pub fn from_config(config: &Config) -> Result, String> { + enabled(config)? + .map(|(delegation, gateway_id)| Self::new(delegation, gateway_id)) + .transpose() + } + + /// Parse `config`, already validated for the gateway `gateway_id`. + fn new(config: &ExperimentalSpiffeDelegationConfig, gateway_id: &str) -> Result { + let prefix = SandboxSpiffeIdPrefix::parse(&config.spiffe_id_prefix)?; + let trust_domain = openshell_core::spiffe::trust_domain(prefix.as_str()) + .and_then(|name| spiffe::TrustDomain::new(name).ok()) + .ok_or("experimental_spiffe_delegation prefix has no valid trust domain")?; + Ok(Self { + admin_socket: PathBuf::from(&config.admin_socket_path), + prefix, + trust_domain, + gateway_id: gateway_id.to_string(), + }) + } + + pub fn gateway_id(&self) -> &str { + &self.gateway_id + } + + /// Stable SPIFFE ID of `sandbox_id` under this gateway. + pub fn sandbox_spiffe_id(&self, sandbox_id: &str) -> Result { + self.prefix + .sandbox_spiffe_id(&self.gateway_id, sandbox_id) + .map_err(|_| configuration_failure()) + } + + /// The caller must authorize the current sandbox session before requesting issuance. + pub async fn fetch_jwt_svid( + &self, + sandbox_id: &str, + audience: &str, + ) -> Result<(String, i64), TokenGrantFailure> { + let expected = self.sandbox_spiffe_id(sandbox_id)?; + let mut agent = AdminClient::connect(&self.admin_socket).await?; + let response = agent + .fetch_jwt_svids(wire::FetchJwtSvidsRequest { + audience: vec![audience.to_string()], + selectors: vec![wire::Selector { + r#type: "openshell".into(), + value: format!("sandbox:{}:{sandbox_id}", self.gateway_id), + }], + pid: 0, + }) + .await?; + let [svid] = response.svids.as_slice() else { + return Err(identity_rejected()); + }; + let reported = svid + .id + .as_ref() + .map(|id| format!("spiffe://{}{}", id.trust_domain, id.path)); + if reported.as_deref() != Some(expected.as_str()) { + return Err(identity_rejected()); + } + let bundles = agent.jwt_bundles(&self.trust_domain).await?; + openshell_crypto::install_jwt_provider(); + let verified = spiffe::JwtSvid::parse_and_validate(&svid.token, &bundles, &[audience]) + .map_err(|_| identity_rejected())?; + if verified.spiffe_id().to_string() != expected { + return Err(identity_rejected()); + } + Ok((svid.token.clone(), verified.claims().exp())) + } +} + +/// Validated delegation settings and gateway ID, when delegation is enabled. +fn enabled(config: &Config) -> Result, String> { + let Some(delegation) = &config.experimental_spiffe_delegation else { + return Ok(None); + }; + let jwt = config + .gateway_jwt + .as_ref() + .ok_or("experimental_spiffe_delegation requires gateway session JWTs")?; + delegation.validate(&jwt.gateway_id)?; + if jwt.ttl_secs.is_none() { + return Err( + "experimental_spiffe_delegation requires a positive gateway_jwt.ttl_secs".into(), + ); + } + Ok(Some((delegation, &jwt.gateway_id))) +} + +fn configuration_failure() -> TokenGrantFailure { + TokenGrantFailure::new( + TokenGrantFailureKind::Configuration, + TokenGrantStage::Configuration, + ) +} + +fn identity_rejected() -> TokenGrantFailure { + TokenGrantFailure::new( + TokenGrantFailureKind::WorkloadIdentityRejected, + TokenGrantStage::WorkloadIdentity, + ) +} + +fn agent_failure(kind: TokenGrantFailureKind) -> TokenGrantFailure { + TokenGrantFailure::new(kind, TokenGrantStage::WorkloadIdentity) +} + +fn status_failure(status: &tonic::Status) -> TokenGrantFailure { + // Classify only by code; the agent's message is not retained. + agent_failure(match status.code() { + tonic::Code::PermissionDenied | tonic::Code::Unauthenticated => { + TokenGrantFailureKind::WorkloadIdentityRejected + } + tonic::Code::DeadlineExceeded => TokenGrantFailureKind::Timeout, + tonic::Code::Unavailable => TokenGrantFailureKind::Unavailable, + tonic::Code::InvalidArgument | tonic::Code::Unimplemented => { + TokenGrantFailureKind::Configuration + } + _ => TokenGrantFailureKind::Unclassified, + }) +} + +/// Client for the SPIRE agent's Delegated Identity API. +struct AdminClient { + grpc: tonic::client::Grpc, +} + +impl AdminClient { + async fn connect(socket: &std::path::Path) -> Result { + // Delegation is authorized by the agent attesting this process over + // a local socket; a TCP endpoint could not attest it. + if !socket.is_absolute() { + return Err(configuration_failure()); + } + let endpoint = + spiffe::transport::endpoint::Endpoint::parse(&format!("unix:{}", socket.display())) + .map_err(|_| configuration_failure())?; + let channel = tokio::time::timeout( + AGENT_CALL_TIMEOUT, + spiffe::transport::connector::connect(&endpoint), + ) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? + .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable))?; + Ok(Self { + grpc: tonic::client::Grpc::new(channel), + }) + } + + async fn fetch_jwt_svids( + &mut self, + request: wire::FetchJwtSvidsRequest, + ) -> Result { + tokio::time::timeout(AGENT_CALL_TIMEOUT, async { + self.ready().await?; + self.grpc + .unary( + tonic::Request::new(request), + http::uri::PathAndQuery::from_static(wire::FETCH_JWT_SVIDS), + tonic_prost::ProstCodec::default(), + ) + .await + .map(tonic::Response::into_inner) + .map_err(|status| status_failure(&status)) + }) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? + } + + /// The agent's current JWT bundle for `trust_domain` only. + async fn jwt_bundles( + &mut self, + trust_domain: &spiffe::TrustDomain, + ) -> Result { + let response: wire::SubscribeToJwtBundlesResponse = + tokio::time::timeout(AGENT_CALL_TIMEOUT, async { + self.ready().await?; + let mut stream = self + .grpc + .server_streaming( + tonic::Request::new(wire::SubscribeToJwtBundlesRequest {}), + http::uri::PathAndQuery::from_static(wire::SUBSCRIBE_TO_JWT_BUNDLES), + tonic_prost::ProstCodec::default(), + ) + .await + .map_err(|status| status_failure(&status))? + .into_inner(); + stream + .message() + .await + .map_err(|status| status_failure(&status))? + .ok_or_else(|| agent_failure(TokenGrantFailureKind::Unavailable)) + }) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))??; + let jwks = response + .bundles + .get(&trust_domain.id_string()) + .ok_or_else(identity_rejected)?; + let bundle = spiffe::JwtBundle::from_jwt_authorities(trust_domain.clone(), jwks) + .map_err(|_| identity_rejected())?; + let mut bundles = spiffe::JwtBundleSet::new(); + bundles.add_bundle(bundle); + Ok(bundles) + } + + async fn ready(&mut self) -> Result<(), TokenGrantFailure> { + self.grpc + .ready() + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable)) + } +} + +/// Wire messages of the SPIRE agent Delegated Identity API. +/// +/// Transcribed from `proto/spire/api/agent/delegatedidentity/v1/delegatedidentity.proto` +/// and `proto/spire/api/types/{selector,jwtsvid,spiffeid}.proto` of +/// at tag `v1.15.3` (commit +/// `adb81b3b0fb87a881693d1d42eaf8b2fa1223e42`), Copyright The SPIFFE Authors, +/// licensed under the Apache License, Version 2.0. Only the messages for +/// `FetchJWTSVIDs` and `SubscribeToJWTBundles` are reproduced; field numbers +/// and types are unchanged. +mod wire { + use super::HashMap; + + pub(super) const FETCH_JWT_SVIDS: &str = + "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/FetchJWTSVIDs"; + pub(super) const SUBSCRIBE_TO_JWT_BUNDLES: &str = + "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToJWTBundles"; + + /// `spire.api.types.Selector` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct Selector { + #[prost(string, tag = "1")] + pub r#type: String, + #[prost(string, tag = "2")] + pub value: String, + } + + /// `spire.api.types.SPIFFEID` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct SpiffeId { + #[prost(string, tag = "1")] + pub trust_domain: String, + #[prost(string, tag = "2")] + pub path: String, + } + + /// `spire.api.types.JWTSVID` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct JwtSvid { + #[prost(string, tag = "1")] + pub token: String, + #[prost(message, optional, tag = "2")] + pub id: Option, + #[prost(int64, tag = "3")] + pub expires_at: i64, + #[prost(int64, tag = "4")] + pub issued_at: i64, + #[prost(string, tag = "5")] + pub hint: String, + } + + /// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsRequest` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct FetchJwtSvidsRequest { + #[prost(string, repeated, tag = "1")] + pub audience: Vec, + #[prost(message, repeated, tag = "2")] + pub selectors: Vec, + #[prost(int32, tag = "3")] + pub pid: i32, + } + + /// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsResponse` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct FetchJwtSvidsResponse { + #[prost(message, repeated, tag = "1")] + pub svids: Vec, + } + + /// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesRequest` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct SubscribeToJwtBundlesRequest {} + + /// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesResponse` + #[derive(Clone, PartialEq, prost::Message)] + pub(super) struct SubscribeToJwtBundlesResponse { + #[prost(map = "string, bytes", tag = "1")] + pub bundles: HashMap>, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + #[ignore = "requires the real SPIRE fixture; run mise run test:spire-delegation"] + async fn gateway_obtains_only_the_expected_sandbox_identity() { + let env = |key| std::env::var(key).expect("SPIRE fixture variable"); + let config = ExperimentalSpiffeDelegationConfig { + admin_socket_path: env("OPENSHELL_TEST_SPIRE_ADMIN_SOCKET"), + spiffe_id_prefix: env("OPENSHELL_TEST_SPIRE_ID_PREFIX"), + admin_socket_group: None, + }; + let gateway = env("OPENSHELL_TEST_SPIRE_GATEWAY_ID"); + config.validate(&gateway).unwrap(); + let delegation = SpiffeDelegation::new(&config, &gateway).unwrap(); + let audience = "https://sts.openshell.test"; + let alpha = env("OPENSHELL_TEST_SPIRE_SANDBOX_ALPHA"); + let beta = env("OPENSHELL_TEST_SPIRE_SANDBOX_BETA"); + let (first, expiry) = delegation.fetch_jwt_svid(&alpha, audience).await.unwrap(); + let (second, _) = delegation.fetch_jwt_svid(&beta, audience).await.unwrap(); + assert_ne!(first, second); + assert!(expiry > 0); + for key in [ + "OPENSHELL_TEST_SPIRE_SANDBOX_UNREGISTERED", + "OPENSHELL_TEST_SPIRE_SANDBOX_MISREGISTERED", + "OPENSHELL_TEST_SPIRE_SANDBOX_AMBIGUOUS", + ] { + assert!( + delegation + .fetch_jwt_svid(&env(key), audience) + .await + .is_err() + ); + } + } +} diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 3b902df1f9..7006205626 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -157,7 +157,7 @@ mod tests { // closures and TokenGrantStatus to public reports. Older rows have no grant // record; absent report extensions retain compatibility without a rewrite. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "a91e2244a2c1a8e369efe85bd0e4f2769f0f29f6263137280fe38e8a8da3cc6e"; + "3b24b6e49b6a4c89655f19a5fcce8133fd82e4faf1f4aa26f582c02fec3a2271"; const DURABLE_SCHEMA_SHA256: &str = "d4dc7abdb91b919db1f7be0d4652e30cbfda86f3b2ed9b59d5de1f76a2bf691d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = @@ -624,7 +624,7 @@ mod tests { } assert_eq!( compiled_method_count, - 103 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 104 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len() + isolation_methods.len() @@ -634,7 +634,7 @@ mod tests { ); assert_eq!( methods.len(), - 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 79 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len(), "inventory every public gateway RPC" @@ -644,7 +644,7 @@ mod tests { .iter() .filter(|method| method.starts_with("openshell.v1.OpenShell/")) .count(), - 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 79 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + PEER_CONFIG_RPC_SIGNATURES.len() ); @@ -695,7 +695,7 @@ mod tests { overlap_hash.as_str(), ), ( - (328, 29), + (330, 29), (95, 22), (83, 22), PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 4d41124c01..2c03654186 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -53,6 +53,14 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 910a54daf8..fb56787c20 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -50,6 +50,14 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn get_sandbox_provider_access_token( + &self, + _request: tonic::Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_notify_config_update( &self, _request: tonic::Request, diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index 7ce403a15e..d07bd7cd3d 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -18,11 +18,23 @@ dependency only. ## Sandbox instance inputs `run_networking` takes a `SandboxInstanceNetwork` with the sandbox identity, -the sandbox instance's `GatewayEndpoint`, the SPIFFE Workload API socket for +the sandbox instance's `GatewayEndpoint`, the `ProviderIdentitySource` for provider token grants, the `policy.local` denial source, and proxy CA files. None of these are read from the process environment, so one process can run -several sandbox instances. Each instance owns its provider token cache and -HTTP client; a token acquired for one sandbox is never served to another. -Without a Workload API socket, token grants fail closed. A process hosting -several instances serves `/v1/denials` from a per-instance -`SandboxInstanceDenialLog` instead of the shared shorthand log files. +several sandbox instances. `run_networking` selects the instance's token grant +resolver once from its identity source; a token acquired for one sandbox is +never served to another. + +A dedicated supervisor uses `SpiffeTokenGrantResolver` with its SPIFFE Workload +API socket. That resolver owns the instance's token cache and HTTP client and +uses the gateway only for the intermediate exchange of RFC 8693 grants. +Without a socket, its grants fail closed. Shared sandbox instances use +`GatewayTokenGrantResolver`, which holds only the instance's authenticated +`GatewayEndpoint`. Each admitted operation forwards a configured dynamic +credential key and request ID; the gateway derives the sandbox identity, +validates current authorization, resolves grant parameters, and obtains the +final access token, which the resolver validates. No SPIRE client, SVID, +intermediate token, or grant cache is used in the shared supervisor. `mise run test:spire-delegation` exercises the gateway's +SPIRE acquisition client against a real server and agent. A process hosting several instances serves +`/v1/denials` from a per-instance `SandboxInstanceDenialLog` instead of the shared +shorthand log files. diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 65d7b70d08..85f066d3f5 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -53,42 +53,6 @@ pub trait TokenGrantResolver: Send + Sync { ) -> Pin> + Send + 'a>>; } -/// Resolves provider tokens through one sandbox instance's authenticated grant flow. -pub struct SpiffeTokenGrantResolver { - grants: crate::token_grant::SandboxInstanceTokenGrants, -} - -impl SpiffeTokenGrantResolver { - #[must_use] - pub fn new(grants: crate::token_grant::SandboxInstanceTokenGrants) -> Self { - Self { grants } - } -} - -impl TokenGrantResolver for SpiffeTokenGrantResolver { - fn obtain<'a>( - &'a self, - request: TokenGrantRequest<'a>, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.grants - .obtain_provider_token(crate::token_grant::ObtainProviderTokenRequest { - provider_name: request.provider_key, - token_endpoint: request.token_endpoint, - jwt_svid_audience: request.jwt_svid_audience, - client_assertion_type: request.client_assertion_type, - audience: request.audience, - scopes: request.scopes, - cache_ttl_override: request.cache_ttl, - grant_type: request.grant_type, - requested_token_type: request.requested_token_type, - request_id: request.request_id, - }) - .await - }) - } -} - /// A refused request: the grant failure and the credential whose grant failed. /// /// The credential is the `provider:credential` name pair of the selected diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 340b3984c5..d624c70798 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -35,11 +35,12 @@ use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, write_ca_files, }; -use crate::l7::token_grant_injection::SpiffeTokenGrantResolver; +use crate::l7::token_grant_injection::TokenGrantResolver; use crate::opa::OpaEngine; use crate::policy_local::{DenialLogSource, PolicyLocalContext}; use crate::proxy::ProxyHandle; -use crate::token_grant::SandboxInstanceTokenGrants; +pub use crate::token_grant::ProviderIdentitySource; +use crate::token_grant::{GatewayTokenGrantResolver, SpiffeTokenGrantResolver}; use openshell_core::endpoint_status::EndpointObservationSender; use openshell_core::grpc_client::GatewayEndpoint; @@ -203,9 +204,8 @@ pub struct SandboxInstanceNetwork { pub sandbox_name: Option, /// Gateway access with this instance's credentials. pub gateway: Option, - /// SPIFFE Workload API socket for provider token grants. Without one, - /// grants fail closed. - pub provider_workload_api_socket: Option, + /// How this instance obtains provider access tokens. + pub provider_identity: ProviderIdentitySource, /// Source served by `policy.local` `/v1/denials`. pub denial_log: DenialLogSource, /// Proxy interception CA and trust-file directory. @@ -304,15 +304,26 @@ pub async fn run_networking( sandbox_id, sandbox_name, gateway, - provider_workload_api_socket, + provider_identity, denial_log, proxy_tls, } = instance; - let token_grants = SpiffeTokenGrantResolver::new(SandboxInstanceTokenGrants::new( - gateway.clone(), - sandbox_id.clone(), - provider_workload_api_socket, - )); + // Choose how this instance obtains provider tokens once, for every grant. + let token_grants: Arc = match (provider_identity, gateway.clone()) { + (ProviderIdentitySource::Gateway, Some(gateway)) => { + Arc::new(GatewayTokenGrantResolver::new(gateway)) + } + // Nothing can issue this sandbox's tokens without a gateway; a + // resolver without a workload identity fails every grant closed. + (ProviderIdentitySource::Gateway, None) => Arc::new(SpiffeTokenGrantResolver::new( + None, + sandbox_id.clone(), + None, + )), + (ProviderIdentitySource::WorkloadApi(socket), gateway) => Arc::new( + SpiffeTokenGrantResolver::new(gateway, sandbox_id.clone(), socket), + ), + }; let policy_local_ctx = Arc::new( PolicyLocalContext::new( retained_proto.cloned(), @@ -322,7 +333,7 @@ pub async fn run_networking( workspace_rx, ) .with_denial_log(denial_log) - .with_token_grants(Arc::new(token_grants)), + .with_token_grants(token_grants), ); // Readiness signal for the proxy accept loop: the proxy binds the TCP diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 83d36d36b3..5c6a91a179 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -29,21 +29,28 @@ //! //! ## Sandbox instance scope //! -//! Each sandbox instance owns a [`SandboxInstanceTokenGrants`]: its token cache, -//! HTTP client, gateway credentials, sandbox ID, and SPIFFE Workload API -//! socket. A token is never served to another sandbox. Without a configured -//! Workload API socket, grants fail closed with a configuration failure. +//! Each sandbox instance selects one resolver when its networking starts, from +//! its [`ProviderIdentitySource`]. A token is never served to another sandbox. +//! +//! A dedicated supervisor uses a [`SpiffeTokenGrantResolver`], which presents +//! the supervisor's own identity from a SPIFFE Workload API socket and owns +//! the instance's token cache and HTTP client. Without a socket, its grants +//! fail closed with a configuration failure. A shared supervisor uses a +//! [`GatewayTokenGrantResolver`], which requests only the final provider access +//! token from the gateway; the gateway owns SPIRE acquisition, exchange, and +//! caching. use std::collections::HashMap; use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use openshell_core::grpc_client::GatewayEndpoint; use openshell_core::oauth::{ self, ACCESS_TOKEN_TYPE, OAuthTokenResponse, TokenExchangeParams, TokenGrantClient, - TokenGrantParams, effective_client_assertion_type, effective_token_type, + TokenGrantParams, effective_client_assertion_type, effective_token_type, token_cache_ttl, }; use openshell_core::proto::ProviderCredentialTokenGrantType; use openshell_core::token_grant_failure::{ @@ -51,9 +58,7 @@ use openshell_core::token_grant_failure::{ }; use spiffe::{WorkloadApiClient, WorkloadApiError}; -const DEFAULT_TOKEN_CACHE_TTL_SECONDS: i64 = 300; -const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; -const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600; +use crate::l7::token_grant_injection::{TokenGrantRequest, TokenGrantResolver}; /// Cached access token with expiration metadata. #[derive(Clone)] @@ -86,7 +91,7 @@ impl TokenCache { } } - /// Store a token with expiration time. + /// Forget every cached token. fn set(&self, provider_name: String, access_token: String, expires_at_ms: i64) { if let Ok(mut tokens) = self.tokens.write() { tokens.insert( @@ -100,12 +105,31 @@ impl TokenCache { } } -/// Provider token grants owned by one supervised sandbox. +/// How a sandbox instance obtains provider access tokens. +#[derive(Debug)] +pub enum ProviderIdentitySource { + /// Acquire tokens with this supervisor's own identity from a SPIFFE + /// Workload API socket, for a supervisor dedicated to one sandbox. + /// Without a socket, every grant fails closed. + WorkloadApi(Option), + /// Request final tokens from the gateway, which owns the sandbox's + /// workload identity. + Gateway, +} + +impl Default for ProviderIdentitySource { + fn default() -> Self { + Self::WorkloadApi(None) + } +} + +/// Provider token grants acquired with this supervisor's SPIFFE identity. /// /// The cache, HTTP connection pool, gateway credentials, sandbox ID, and -/// workload identity socket all belong to this sandbox instance, so a token acquired -/// for one sandbox is never served to another. -pub struct SandboxInstanceTokenGrants { +/// Workload API socket all belong to one sandbox instance, so a token +/// acquired for one sandbox is never served to another. The gateway is used +/// only for the intermediate exchange of RFC 8693 token exchange grants. +pub struct SpiffeTokenGrantResolver { cache: TokenCache, // Initialization failures stay bounded and typed; acquisition must not panic. http_client: Result, @@ -114,9 +138,9 @@ pub struct SandboxInstanceTokenGrants { workload_api_socket: Option, } -impl SandboxInstanceTokenGrants { - /// Grants authenticated as `sandbox_id` through `gateway`. A missing - /// Workload API socket makes every grant fail closed. +impl SpiffeTokenGrantResolver { + /// Grants authenticated as `sandbox_id` through `gateway`. A missing or + /// empty socket makes every grant fail closed. #[must_use] pub fn new( gateway: Option, @@ -132,6 +156,14 @@ impl SandboxInstanceTokenGrants { } } + async fn fetch_jwt_svid(&self, audience: &str) -> Result { + let socket_path = self + .workload_api_socket + .as_deref() + .ok_or_else(configuration_failure)?; + fetch_jwt_svid_from_socket(socket_path, audience).await + } + /// Obtain an `OAuth2` access token for a provider using JWT client assertion grant. /// /// This fetches the sandbox's SPIFFE JWT-SVID from the instance's Workload @@ -150,22 +182,22 @@ impl SandboxInstanceTokenGrants { /// - JWT-SVID fetch fails /// - Token service request fails /// - Token response is invalid - pub async fn obtain_provider_token( + async fn obtain_provider_token( &self, - request: ObtainProviderTokenRequest<'_>, + request: TokenGrantRequest<'_>, ) -> Result { let grant_type = ProviderCredentialTokenGrantType::try_from(request.grant_type) .map_err(|_| configuration_failure())?; - obtain_provider_token_with_grant( + let token = obtain_provider_token_with_grant( ObtainProviderTokenInput { cache: &self.cache, - provider_name: request.provider_name, + provider_name: request.provider_key, token_endpoint: request.token_endpoint, jwt_svid_audience: request.jwt_svid_audience, client_assertion_type: request.client_assertion_type, audience: request.audience, scopes: request.scopes, - cache_ttl_override: request.cache_ttl_override, + cache_ttl_override: request.cache_ttl, grant_type, requested_token_type: request.requested_token_type, }, @@ -176,7 +208,7 @@ impl SandboxInstanceTokenGrants { | ProviderCredentialTokenGrantType::Unspecified => None, ProviderCredentialTokenGrantType::TokenExchange => { let (provider, credential_key) = - parse_provider_credential_key(request.provider_name)?; + parse_provider_credential_key(request.provider_key)?; let gateway = self.gateway.as_ref().ok_or_else(configuration_failure)?; openshell_core::grpc_client::token_grant_gateway_endpoint(gateway.url())?; let sandbox_id = self @@ -187,15 +219,11 @@ impl SandboxInstanceTokenGrants { } }; let http_client = self.http_client.as_ref().map_err(|failure| *failure)?; - let socket_path = self - .workload_api_socket - .as_deref() - .ok_or_else(configuration_failure)?; // Fetch JWT-SVID with authorization server as audience // For RFC 7523, the JWT assertion's aud claim identifies the issuer/realm - let jwt_svid = fetch_jwt_svid_from_socket(socket_path, &jwt_audience).await?; + let jwt_svid = self.fetch_jwt_svid(&jwt_audience).await?; - match exchange_context { + let response = match exchange_context { None => { // Perform OAuth2 JWT client assertion grant. The audience // parameter in the token request specifies the resource server. @@ -240,27 +268,58 @@ impl SandboxInstanceTokenGrants { ) .await } - } + }?; + Ok(response) }, ) - .await + .await?; + Ok(token) + } +} + +impl TokenGrantResolver for SpiffeTokenGrantResolver { + fn obtain<'a>( + &'a self, + request: TokenGrantRequest<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(self.obtain_provider_token(request)) } } -/// One provider token request; see [`SandboxInstanceTokenGrants::obtain_provider_token`]. -pub struct ObtainProviderTokenRequest<'a> { - pub provider_name: &'a str, - pub token_endpoint: &'a str, - pub jwt_svid_audience: &'a str, - pub client_assertion_type: &'a str, - pub audience: &'a str, - pub scopes: &'a [String], - pub cache_ttl_override: Option, - pub grant_type: i32, - pub requested_token_type: &'a str, - /// Supervisor-minted ID of the HTTP exchange that needs the token. It is - /// sent to the gateway and token service; a cache hit sends nothing. - pub request_id: &'a str, +/// Final provider access tokens from the gateway, for a sandbox instance whose +/// supervisor holds no workload identity of its own. +/// +/// It forwards only the credential key and request ID over the instance's +/// authenticated gateway connection and validates the returned token. The +/// gateway authorizes, acquires, exchanges, and caches; this resolver holds +/// no cache, SVID, intermediate token, or HTTP client. +pub struct GatewayTokenGrantResolver { + gateway: GatewayEndpoint, +} + +impl GatewayTokenGrantResolver { + #[must_use] + pub fn new(gateway: GatewayEndpoint) -> Self { + Self { gateway } + } +} + +impl TokenGrantResolver for GatewayTokenGrantResolver { + fn obtain<'a>( + &'a self, + request: TokenGrantRequest<'a>, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let token = openshell_core::grpc_client::get_sandbox_provider_access_token( + &self.gateway, + request.provider_key, + Some(request.request_id), + ) + .await?; + validate_access_token(&token.access_token)?; + Ok(token.access_token) + }) + } } struct ObtainProviderTokenInput<'a> { @@ -413,23 +472,6 @@ async fn perform_token_exchange( pub use oauth::validate_access_token; -fn token_cache_ttl(cache_ttl_override: Option, expires_in: i64) -> Duration { - if let Some(cache_ttl_override) = cache_ttl_override { - return cache_ttl_override; - } - - let ttl = if expires_in > 0 { - expires_in.min(MAX_TOKEN_EXPIRES_IN_SECONDS) - } else { - DEFAULT_TOKEN_CACHE_TTL_SECONDS - }; - - Duration::from_secs( - u64::try_from(ttl.saturating_sub(TOKEN_CACHE_EXPIRY_SKEW_SECONDS).max(1)) - .unwrap_or(u64::MAX), - ) -} - /// Derive the issuer/realm URL from a token endpoint URL. /// /// For Keycloak token endpoints like: @@ -512,9 +554,8 @@ pub mod test_support { //! socket or OAuth service is contacted, and no process-wide cache is used. use super::*; - use crate::l7::token_grant_injection::{TokenGrantRequest, TokenGrantResolver}; + use openshell_core::oauth::MAX_TOKEN_EXPIRES_IN_SECONDS; use std::collections::HashSet; - use std::pin::Pin; use std::sync::Mutex; /// One actual acquisition, excluding requests served from the cache. @@ -652,7 +693,10 @@ pub mod test_support { #[cfg(test)] mod tests { use super::*; - use openshell_core::oauth::{ACCESS_TOKEN_TYPE, DEFAULT_CLIENT_ASSERTION_TYPE}; + use openshell_core::oauth::{ + ACCESS_TOKEN_TYPE, DEFAULT_CLIENT_ASSERTION_TYPE, DEFAULT_TOKEN_CACHE_TTL_SECONDS, + MAX_TOKEN_EXPIRES_IN_SECONDS, TOKEN_CACHE_EXPIRY_SKEW_SECONDS, + }; use std::collections::HashMap; use std::sync::{ Arc, diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index 10964b8542..9083f62d74 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -64,8 +64,9 @@ is a single path component, then, under the registry lock, accepts the same launch fingerprint for an occupied key again, rejects a different one, and rejects any admission while the key is being removed. A new instance gets an empty private directory under the host root, and `sandbox_instance_launch` -prepares its bindings and body there. A failed admission leaves no entry and -no files. +prepares its bindings and body there. A failed admission retains no entry; +its directory is deleted on a best-effort basis, and the next admission of the +key must delete any stale files before reusing it. Removing an instance stops its workload, shuts down only its runtime, which drops its runtime route, and revokes its gateway credentials; only then is its @@ -179,10 +180,13 @@ control listener: it dials the gateway and receives its assignments from it. reports only that the process runs. SIGTERM and SIGINT stop every instance, which ends their workloads' supervision. -Sandbox instances get no upstream proxy, extra CA bundle, or provider workload identity: -the Kubernetes driver refuses the topology when an upstream proxy or provider -SPIFFE identity is configured, so a workload never silently loses them. The -host also initializes no OTLP trace or OCSF exporters, because one process +Sandbox instances get no upstream proxy or extra CA bundle: the Kubernetes driver +refuses the topology when an upstream proxy is configured, so a workload never +silently loses it. The process's own SPIFFE identity never stands in for a +sandbox. Shared sandbox instances obtain final provider access tokens through the gateway's +`GetSandboxProviderAccessToken` RPC. They hold no SPIRE socket, SVID, or +intermediate token. The gateway owns identity authorization, acquisition, +exchange, and caching. The host also initializes no OTLP trace or OCSF exporters, because one process cannot export each instance's records with that instance's collector headers and identity. Its console logs and each instance's gateway log push remain, and the Kubernetes driver warns at startup when the gateway configures supervisor diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index b4704bf623..06881602e1 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -701,7 +701,7 @@ async fn shared_startup_uses_selected_backend_through_readiness_and_shutdown() { admitted_isolation_backend: Some(TEST_BACKEND.into()), main_exit_marker: Some(marker.clone()), trace_context: None, - provider_workload_api_socket: None, + provider_identity: openshell_supervisor_network::run::ProviderIdentitySource::default(), // The binary loads the proxy trust directory from its environment. proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles::from_environment(), }; diff --git a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs index 699880f1ad..212d9dd64a 100644 --- a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs +++ b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs @@ -302,7 +302,7 @@ async fn config_push_regression_legacy_gateway_never_observes_ready_before_workl admitted_isolation_backend: Some("test-held".into()), main_exit_marker: None, trace_context: None, - provider_workload_api_socket: None, + provider_identity: openshell_supervisor_network::run::ProviderIdentitySource::default(), proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles::default(), }; let bindings = SandboxInstanceBindings::dedicated( diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 46eb9e8839..438d77ac56 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -788,8 +788,8 @@ pub struct SandboxInstanceConfig { pub main_exit_marker: Option, /// Parent of the startup span. pub trace_context: Option, - /// SPIFFE Workload API socket for provider token grants; absent fails closed. - pub provider_workload_api_socket: Option, + /// How this instance obtains provider access tokens. + pub provider_identity: openshell_supervisor_network::run::ProviderIdentitySource, pub proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles, } @@ -937,7 +937,7 @@ pub async fn run_sandbox_instance( admitted_isolation_backend, main_exit_marker, trace_context, - provider_workload_api_socket, + provider_identity, proxy_tls, } = config; // The owner binds the event context and log sink to the instance's @@ -1277,7 +1277,7 @@ pub async fn run_sandbox_instance( sandbox_id: sandbox_id.clone(), sandbox_name: sandbox_name_for_agg.clone(), gateway: gateway.clone(), - provider_workload_api_socket, + provider_identity, denial_log, proxy_tls, }, @@ -4365,7 +4365,7 @@ mod tests { admitted_isolation_backend: None, main_exit_marker: None, trace_context: None, - provider_workload_api_socket: None, + provider_identity: openshell_supervisor_network::run::ProviderIdentitySource::default(), proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles::default(), }; let bindings = SandboxInstanceBindings::dedicated( diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index de29e9509a..c5719fe00e 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -590,11 +590,14 @@ fn main() -> Result<()> { admitted_isolation_backend: Some(admitted_backend), main_exit_marker: args.main_exit_marker, trace_context: openshell_supervisor::trace_context_from_environment(), - provider_workload_api_socket: std::env::var_os( - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, - ) - .filter(|path| !path.is_empty()) - .map(PathBuf::from), + provider_identity: + openshell_supervisor_network::run::ProviderIdentitySource::WorkloadApi( + std::env::var_os( + openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, + ) + .filter(|path| !path.is_empty()) + .map(PathBuf::from), + ), proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles::from_environment(), }; // Log push already shares these credentials and the logging diff --git a/crates/openshell-supervisor/src/sandbox_instance_host.rs b/crates/openshell-supervisor/src/sandbox_instance_host.rs index 66aa30e57e..5080253f9b 100644 --- a/crates/openshell-supervisor/src/sandbox_instance_host.rs +++ b/crates/openshell-supervisor/src/sandbox_instance_host.rs @@ -251,7 +251,9 @@ impl SandboxInstanceHost { /// /// Rejects an occupied key with different launch material or one still /// being removed, and fails when the instance cannot be prepared or - /// started. A failed admission leaves no instance and no private files. + /// started. A failed admission retains no instance; deleting its private + /// directory is best effort, and the next admission of the key deletes + /// any stale files before reuse. pub fn admit>( &self, admission: SandboxInstanceAdmission, diff --git a/crates/openshell-supervisor/src/shared_host.rs b/crates/openshell-supervisor/src/shared_host.rs index b78ce7469c..a0dfd133ee 100644 --- a/crates/openshell-supervisor/src/shared_host.rs +++ b/crates/openshell-supervisor/src/shared_host.rs @@ -370,8 +370,8 @@ impl SharedHost { ssh_socket_path: Some(ssh_socket.to_string_lossy().into_owned()), shared_ssh_socket: false, // The Kubernetes driver refuses the gateway topology when an - // upstream proxy or provider workload identity is configured, - // and it configures no extra CA for dedicated supervisors. + // upstream proxy is configured, and it configures no extra CA + // for dedicated supervisors. upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs::default(), additional_ca_bundle: None, @@ -380,9 +380,9 @@ impl SharedHost { admitted_isolation_backend: Some(openshell_sandbox_backend::BACKEND_NAME.to_string()), main_exit_marker: None, trace_context: registration.trace_context, - // One process cannot hold a per-sandbox workload identity, so - // providers that need one fail closed. - provider_workload_api_socket: None, + // The gateway owns each sandbox's workload identity and returns + // only final provider access tokens. + provider_identity: openshell_supervisor_network::run::ProviderIdentitySource::Gateway, proxy_tls: openshell_supervisor_network::run::ProxyTlsFiles { ca_certificate: Some(ca_certificate), ca_private_key: Some(ca_private_key), diff --git a/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml b/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml new file mode 100644 index 0000000000..00bdd6e505 --- /dev/null +++ b/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# SPIRE hardened chart values for the shared-supervisor SPIRE delegation e2e. +# The agent exposes its admin socket on the host for the gateway +# only, and authorizes the gateway's default SPIFFE ID +# (spiffe:///ns//sa/) as a delegate. +# Sandbox registration entries are created by the test, as an operator would. +global: + spire: + clusterName: openshell-e2e + jwtIssuer: https://spire.openshell.local + trustDomain: openshell.local + +spire-server: + defaultJwtSvidTTL: 5m + +spire-agent: + # The agent keeps root for workload attestation but creates its admin + # socket (mode 0770) with this group, which only the gateway + # joins (experimental_spiffe_delegation.admin_socket_group). + podSecurityContext: + runAsUser: 0 + runAsGroup: 10035 + authorizedDelegates: + - spiffe://openshell.local/ns/openshell/sa/openshell + sockets: + admin: + enabled: true + mountOnHost: true + +spiffe-oidc-discovery-provider: + enabled: false diff --git a/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml new file mode 100644 index 0000000000..c6162d236e --- /dev/null +++ b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# E2E overlay: the gateway obtains each sandbox's stable SPIFFE +# identity from the node's SPIRE agent (ci/values-spire-delegation-stack.yaml) +# for provider token grants. The gateway keeps its own Workload API identity +# for the RFC 8693 intermediate exchange. +gatewayConfig: + openshell.drivers.kubernetes: + supervisor_topology: gateway + provider_spiffe_workload_api_socket_path: /spiffe-workload-api/spire-agent.sock + openshell.gateway.experimental_spiffe_delegation: + admin_socket_path: /run/spire/agent/sockets/csi.spiffe.io/admin/admin.sock + spiffe_id_prefix: spiffe://openshell.local/openshell + admin_socket_group: 10035 diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 12120a89ce..52a05ada1c 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -9,6 +9,7 @@ Gateway pod template shared by the StatefulSet and Deployment workload shapes. {{- $oidcRuntimeConfig := get $gatewayConfig "openshell.gateway.oidc" | default dict -}} {{- if not (get $oidcRuntimeConfig "issuer") -}}{{- $oidcRuntimeConfig = .Values.server.oidc | default dict -}}{{- end -}} {{- $kubernetesRuntimeConfig := include "openshell.effectiveKubernetesConfig" . | fromYaml -}} +{{- $delegation := get $gatewayConfig "openshell.gateway.experimental_spiffe_delegation" | default dict -}} {{- $spiffeSocketPath := get $kubernetesRuntimeConfig "provider_spiffe_workload_api_socket_path" -}} {{- $hasExternalCredentialDriver := or (eq (include "openshell.credentialDriverEnabled" (list . "kubernetes-secrets")) "true") (eq (include "openshell.credentialDriverEnabled" (list . "vault")) "true") -}} {{- $vaultCredentialDriverEnabled := eq (include "openshell.credentialDriverEnabled" (list . "vault")) "true" -}} @@ -46,7 +47,11 @@ spec: - host.docker.internal - host.openshell.internal {{- end }} - {{- with .Values.podSecurityContext }} + {{- $podSecurity := deepCopy (.Values.podSecurityContext | default dict) }} + {{- with $delegation.admin_socket_group }} + {{- $_ := set $podSecurity "supplementalGroups" (append (get $podSecurity "supplementalGroups" | default list) (int64 .) | uniq) }} + {{- end }} + {{- with $podSecurity }} securityContext: {{- toYaml . | nindent 4 }} {{- end }} @@ -196,6 +201,11 @@ spec: mountPath: {{ dir $spiffeSocketPath | quote }} readOnly: true {{- end }} + {{- if $delegation }} + - name: spire-agent-admin + mountPath: {{ dir (get $delegation "admin_socket_path") | quote }} + readOnly: true + {{- end }} {{- with .Values.server.extraVolumeMounts }} {{- toYaml . | nindent 8 }} {{- end }} @@ -298,6 +308,12 @@ spec: - key: {{ .Values.upstreamProxy.caBundle.key | default "ca.crt" | quote }} path: ca.crt {{- end }} + {{- if $delegation }} + - name: spire-agent-admin + hostPath: + path: {{ dir (get $delegation "admin_socket_path") | quote }} + type: Directory + {{- end }} {{- if $spiffeSocketPath }} - name: spiffe-workload-api csi: diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 44a1a8a8bb..d5ce02e91d 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -378,6 +378,26 @@ fullname, so the name never collapses into another release resource's name. {{- printf "%s%s" $base $suffix -}} {{- end }} +{{/* +SPIRE delegation belongs to the gateway. The private node admin socket is +never mounted in a supervisor or sandbox Pod. +*/}} +{{- define "openshell.validateSpiffeDelegation" -}} +{{- $delegation := .delegation -}} +{{- $admin := get $delegation "admin_socket_path" | default "" -}} +{{- $prefix := get $delegation "spiffe_id_prefix" | default "" -}} +{{- $canonical := "^(/[A-Za-z0-9_.-]+){2,}$" -}} +{{- if or (not (regexMatch $canonical $admin)) (regexMatch "/\\.\\.?(/|$)" $admin) -}} +{{- fail "experimental_spiffe_delegation.admin_socket_path must be a canonical absolute UNIX socket path below a dedicated directory" -}} +{{- end -}} +{{- if and (hasKey $delegation "admin_socket_group") (not (regexMatch "^[1-9][0-9]*$" (toString (get $delegation "admin_socket_group")))) -}} +{{- fail "experimental_spiffe_delegation.admin_socket_group must be a positive group ID" -}} +{{- end -}} +{{- if not (regexMatch "^spiffe://[a-z0-9._-]+(/[A-Za-z0-9._-]+)*$" $prefix) -}} +{{- fail "experimental_spiffe_delegation.spiffe_id_prefix must be spiffe://[/...]" -}} +{{- end -}} +{{- end }} + {{/* Pod labels of the shared supervisor; its Service selects exactly these. */}} @@ -641,8 +661,8 @@ Validate chart values that Helm would otherwise accept silently. {{- if ne (get $gateway "compute_driver" | default "kubernetes") "kubernetes" -}} {{- fail "supervisor_topology requires the kubernetes compute driver" -}} {{- end -}} -{{- if or (get $kubernetes "https_proxy") (get $kubernetes "proxy_ca_bundle") (get $kubernetes "proxy_auth_secret_name") (get $kubernetes "provider_spiffe_workload_api_socket_path") -}} -{{- fail "supervisor_topology=gateway does not support upstream proxy settings or provider SPIFFE identity" -}} +{{- if or (get $kubernetes "https_proxy") (get $kubernetes "proxy_ca_bundle") (get $kubernetes "proxy_auth_secret_name") -}} +{{- fail "supervisor_topology=gateway does not support upstream proxy settings" -}} {{- end -}} {{- if .Values.server.disableTls -}} {{- fail "supervisor_topology=gateway requires gateway TLS; the supervisor token travels only over TLS" -}} @@ -651,6 +671,13 @@ Validate chart values that Helm would otherwise accept silently. {{- fail "supervisor_topology=gateway in operator workspace mode requires operator_namespace_label of the form key=value" -}} {{- end -}} {{- end -}} +{{- $delegation := get (.Values.gatewayConfig | default dict) "openshell.gateway.experimental_spiffe_delegation" | default dict -}} +{{- if $delegation -}} +{{- if ne (get $kubernetes "supervisor_topology" | default "") "gateway" -}} +{{- fail "experimental_spiffe_delegation requires supervisor_topology=gateway" -}} +{{- end -}} +{{- include "openshell.validateSpiffeDelegation" (dict "delegation" $delegation) -}} +{{- end -}} {{- if hasKey (get (.Values.gatewayConfig | default dict) "openshell.drivers.kubernetes" | default dict) "shared_supervisor" -}} {{- fail "shared_supervisor is rendered by the chart; set only supervisor_topology" -}} {{- end -}} diff --git a/deploy/helm/openshell/tests/supervisor_test.yaml b/deploy/helm/openshell/tests/supervisor_test.yaml index 0686a800ec..b58df0cfdc 100644 --- a/deploy/helm/openshell/tests/supervisor_test.yaml +++ b/deploy/helm/openshell/tests/supervisor_test.yaml @@ -282,7 +282,7 @@ tests: supervisor_topology: gateway asserts: - failedTemplate: - errorMessage: supervisor_topology=gateway does not support upstream proxy settings or provider SPIFFE identity + errorMessage: supervisor_topology=gateway does not support upstream proxy settings - it: rejects workspace until implemented template: templates/supervisor.yaml set: diff --git a/deploy/helm/openshell/tests/supervisor_wiring_test.yaml b/deploy/helm/openshell/tests/supervisor_wiring_test.yaml index b37c16e295..aa60b93c9a 100644 --- a/deploy/helm/openshell/tests/supervisor_wiring_test.yaml +++ b/deploy/helm/openshell/tests/supervisor_wiring_test.yaml @@ -104,3 +104,49 @@ tests: name: supervisor-token secret: secretName: openshell-supervisor-token + - it: omits the gateway admin socket when delegation is disabled + template: templates/statefulset.yaml + set: + gatewayConfig: + openshell.drivers.kubernetes: + supervisor_topology: gateway + provider_spiffe_workload_api_socket_path: /spiffe-workload-api/spire-agent.sock + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: spiffe-workload-api + mountPath: /spiffe-workload-api + readOnly: true + - notContains: + path: spec.template.spec.volumes + content: + name: spire-agent-admin + any: true + - it: mounts SPIRE delegation only into the gateway + template: templates/statefulset.yaml + set: + gatewayConfig: + openshell.drivers.kubernetes: + supervisor_topology: gateway + openshell.gateway.experimental_spiffe_delegation: + admin_socket_path: /run/spire/admin/admin.sock + spiffe_id_prefix: spiffe://example.org/openshell + admin_socket_group: 10035 + asserts: + - contains: + path: spec.template.spec.securityContext.supplementalGroups + content: 10035 + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: spire-agent-admin + mountPath: /run/spire/admin + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: spire-agent-admin + hostPath: + path: /run/spire/admin + type: Directory diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 1dc6f0b1cf..dbb9900885 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -38,6 +38,8 @@ e2e-kubernetes-workspace-managed = ["e2e-kubernetes"] e2e-kubernetes-workspace-operator = ["e2e-kubernetes"] # One supervisor process shared by the gateway's sandboxes. e2e-kubernetes-shared-supervisor = ["e2e-kubernetes", "e2e-host-gateway"] +# The shared supervisor acting as sandbox identities delegated by SPIRE. +e2e-kubernetes-shared-supervisor-spiffe = ["e2e-kubernetes-shared-supervisor"] e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-oidc-pkce = [] diff --git a/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs b/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs new file mode 100644 index 0000000000..0c9aa579d4 --- /dev/null +++ b/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs @@ -0,0 +1,877 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-kubernetes-shared-supervisor-spiffe")] + +//! Sandbox identities delegated by SPIRE to the shared supervisor. +//! +//! The wrapper installs SPIRE (`ci/values-spire-delegation-stack.yaml`) and +//! the gateway topology with SPIRE delegation +//! (`ci/values-supervisor-spiffe.yaml`). This test registers +//! sandbox identities as an operator would, with `ClusterStaticEntry` +//! resources, and drives the existing RFC 8693 provider exchange: the gateway +//! exchanges the stored subject token with its own SVID for the sandbox's +//! SPIFFE ID, and the shared supervisor exchanges that intermediate token with +//! the sandbox's delegated SVID. The token service is a host fixture reached +//! through a selectorless Service, and it hands out tokens that name the +//! identity they were issued to. + +use std::collections::HashMap; +use std::io::Write as _; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine as _; +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tempfile::NamedTempFile; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; + +const TEST_HOST: &str = "host.openshell.internal"; +/// Matches `ci/values-spire-delegation-stack.yaml`. +const TRUST_DOMAIN: &str = "openshell.local"; +const SPIRE_CLUSTER: &str = "openshell-e2e"; +/// Matches `ci/values-supervisor-spiffe.yaml`. +const ID_PREFIX: &str = "spiffe://openshell.local/openshell"; +/// Label on the registration entries this run creates, naming its SPIRE +/// fixture so cleanup never touches another run's entries. +const ENTRY_LABEL: &str = "openshell.ai/e2e-spire-fixture"; +const STS_SERVICE: &str = "openshell-e2e-sts"; +const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; +const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token"; +const TOPOLOGY_ANNOTATION: &str = "openshell.ai/sandbox-runtime-supervisor-topology"; +/// Instance route annotation; the key keeps its established `-session` name. +const INSTANCE_ROUTE_ANNOTATION: &str = "openshell.ai/sandbox-runtime-supervisor-session"; + +fn release_namespace() -> String { + std::env::var("OPENSHELL_E2E_KUBE_NAMESPACE").unwrap_or_else(|_| "openshell".to_string()) +} + +fn kubectl_command() -> tokio::process::Command { + let mut command = tokio::process::Command::new("kubectl"); + if let Ok(context) = std::env::var("OPENSHELL_E2E_KUBE_CONTEXT_ACTIVE") + && !context.trim().is_empty() + { + command.arg("--context").arg(context); + } + command +} + +async fn kubectl_with_input(args: &[&str], input: Option<&str>) -> String { + let mut child = kubectl_command() + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|error| panic!("spawn kubectl {args:?}: {error}")); + let mut stdin = child.stdin.take().expect("kubectl stdin"); + if let Some(input) = input { + stdin + .write_all(input.as_bytes()) + .await + .expect("write kubectl input"); + } + drop(stdin); + let output = child.wait_with_output().await.expect("wait for kubectl"); + assert!( + output.status.success(), + "kubectl {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +async fn kubectl(args: &[&str]) -> String { + kubectl_with_input(args, None).await +} + +async fn cli(args: &[&str]) -> (bool, String) { + let output = openshell_cmd() + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .expect("spawn openshell CLI"); + ( + output.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + ) +} + +fn temp_yaml(contents: &str) -> NamedTempFile { + let mut file = tempfile::Builder::new() + .suffix(".yaml") + .tempfile() + .expect("create temp file"); + file.write_all(contents.as_bytes()) + .expect("write temp file"); + file.flush().expect("flush temp file"); + file +} + +fn spiffe_id(gateway_id: &str, sandbox_id: &str) -> String { + format!("{ID_PREFIX}/gateway/{gateway_id}/sandbox/{sandbox_id}") +} + +/// Claims of a JWT, decoded for observation only. +fn claims(token: &str) -> serde_json::Value { + let payload = token.split('.').nth(1).unwrap_or_default(); + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +/// One exchange the token service answered. +#[derive(Clone, Debug)] +struct Exchange { + /// Subject of the client assertion. + assertion_subject: String, + /// `audience` parameter of the request. + audience: String, + /// Whether the subject token was the stored user token (the gateway's + /// intermediate exchange) rather than an intermediate token. + intermediate: bool, +} + +/// RFC 8693 token service that binds each intermediate token to the SPIFFE ID +/// it was issued for, and issues a final token only to that identity. +struct TokenService { + port: u16, + state: Arc>, + task: tokio::task::JoinHandle<()>, +} + +#[derive(Default)] +struct TokenState { + exchanges: Vec, + /// Intermediate token -> the SPIFFE ID it was issued for. + intermediates: HashMap, +} + +impl TokenService { + async fn start(stored_token: String) -> Self { + let listener = TcpListener::bind(("0.0.0.0", 0)) + .await + .expect("bind token service"); + let port = listener.local_addr().expect("token service address").port(); + let state = Arc::new(Mutex::new(TokenState::default())); + let (shared, stored) = (state.clone(), Arc::new(stored_token)); + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (state, stored) = (shared.clone(), stored.clone()); + tokio::spawn(async move { + let _ = answer_token_request(stream, &state, &stored).await; + }); + } + }); + Self { port, state, task } + } + + fn exchanges(&self) -> Vec { + self.state.lock().unwrap().exchanges.clone() + } +} + +impl Drop for TokenService { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn read_request(stream: &mut TcpStream) -> std::io::Result<(String, String)> { + let mut request = Vec::new(); + let mut chunk = [0_u8; 8192]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + let text = String::from_utf8_lossy(&request); + if let Some((head, body)) = text.split_once("\r\n\r\n") { + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + if body.len() >= length { + return Ok((head.to_string(), body.to_string())); + } + } + } + let text = String::from_utf8_lossy(&request).into_owned(); + Ok(text + .split_once("\r\n\r\n") + .map(|(head, body)| (head.to_string(), body.to_string())) + .unwrap_or((text, String::new()))) +} + +fn form(body: &str) -> HashMap { + url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect() +} + +async fn respond(stream: &mut TcpStream, status: &str, body: &str) -> std::io::Result<()> { + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await +} + +async fn answer_token_request( + mut stream: TcpStream, + state: &Mutex, + stored: &str, +) -> std::io::Result<()> { + let (_, body) = read_request(&mut stream).await?; + let fields = form(&body); + let field = |name: &str| fields.get(name).cloned().unwrap_or_default(); + let assertion_subject = claims(&field("client_assertion"))["sub"] + .as_str() + .unwrap_or_default() + .to_string(); + let well_formed = field("grant_type") == "urn:ietf:params:oauth:grant-type:token-exchange" + && field("client_assertion_type") == CLIENT_ASSERTION_TYPE + && field("subject_token_type") == TOKEN_TYPE_ACCESS_TOKEN + && assertion_subject.starts_with("spiffe://"); + let issued = { + let mut state = state.lock().unwrap(); + let subject_token = field("subject_token"); + let audience = field("audience"); + if !well_formed { + None + } else if subject_token == stored { + // The gateway exchanges the user's token for the sandbox identity + // named in `audience`. + let token = format!("intermediate-{}", state.intermediates.len() + 1); + state.intermediates.insert(token.clone(), audience.clone()); + state.exchanges.push(Exchange { + assertion_subject, + audience, + intermediate: true, + }); + Some(token) + } else if state.intermediates.get(&subject_token) == Some(&assertion_subject) { + // Only the identity an intermediate token names may redeem it. + let sandbox = assertion_subject.rsplit('/').next().unwrap_or_default(); + let token = format!("final-{sandbox}-{}", state.exchanges.len() + 1); + state.exchanges.push(Exchange { + assertion_subject, + audience, + intermediate: false, + }); + Some(token) + } else { + None + } + }; + match issued { + Some(token) => { + let body = format!( + r#"{{"access_token":"{token}","issued_token_type":"{TOKEN_TYPE_ACCESS_TOKEN}","token_type":"Bearer","expires_in":300}}"# + ); + respond(&mut stream, "200 OK", &body).await + } + None => { + respond( + &mut stream, + "400 Bad Request", + r#"{"error":"invalid_grant"}"#, + ) + .await + } + } +} + +/// Protected endpoint that reports which sandbox a final token was issued to. +struct Target { + port: u16, + task: tokio::task::JoinHandle<()>, +} + +impl Target { + async fn start() -> Self { + let listener = TcpListener::bind(("0.0.0.0", 0)) + .await + .expect("bind protected target"); + let port = listener.local_addr().expect("target address").port(); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let Ok((head, _)) = read_request(&mut stream).await else { + return; + }; + let authorization = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("authorization") + .then(|| value.trim().to_string()) + }) + .unwrap_or_default(); + // "Bearer final--" -> "SAW:" + let seen = authorization + .strip_prefix("Bearer final-") + .and_then(|rest| rest.rsplit_once('-')) + .map_or_else(|| "SAW:NONE".to_string(), |(id, _)| format!("SAW:{id}")); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{seen}", + seen.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + Self { port, task } + } +} + +impl Drop for Target { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// A selectorless Service in front of the host token service, so token +/// endpoints are the in-cluster `http://..svc` URLs that +/// gateways and supervisors accept without TLS. +async fn expose_token_service(port: u16) -> String { + let host_ip = std::env::var("OPENSHELL_E2E_HOST_GATEWAY_IP") + .ok() + .filter(|ip| !ip.is_empty()) + .expect("OPENSHELL_E2E_HOST_GATEWAY_IP is set by with-kube-gateway.sh"); + let namespace = release_namespace(); + let manifest = format!( + r"apiVersion: v1 +kind: Service +metadata: + name: {STS_SERVICE} + namespace: {namespace} +spec: + ports: + - name: http + port: 80 + targetPort: {port} +--- +apiVersion: discovery.k8s.io/v1 +kind: EndpointSlice +metadata: + name: {STS_SERVICE} + namespace: {namespace} + labels: + kubernetes.io/service-name: {STS_SERVICE} +addressType: IPv4 +ports: + - name: http + port: {port} +endpoints: + - addresses: [{host_ip}] +" + ); + kubectl_with_input(&["apply", "-f", "-"], Some(&manifest)).await; + format!("http://{STS_SERVICE}.{namespace}.svc.cluster.local/token") +} + +async fn remove_token_service() { + let namespace = release_namespace(); + let _ = kubectl_command() + .args([ + "-n", + &namespace, + "delete", + "service,endpointslice", + STS_SERVICE, + "--ignore-not-found", + ]) + .output() + .await; +} + +/// A provider profile whose bearer credential comes from the existing RFC +/// 8693 exchange, bound to `target_port` on the test host. +fn profile(id: &str, token_endpoint: &str, target_port: u16) -> NamedTempFile { + temp_yaml(&format!( + r"id: {id} +display_name: E2E delegated sandbox identity +description: Token exchange with the sandbox's SPIRE-delegated SVID +category: other +credentials: + - name: subject_token + description: Stored user subject token + required: true + - name: access_token + description: Access token obtained through token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: {token_endpoint} + audience: e2e-service + jwt_svid_audience: {token_endpoint} + client_assertion_type: {CLIENT_ASSERTION_TYPE} + requested_token_type: {TOKEN_TYPE_ACCESS_TOKEN} + cache_ttl_seconds: 300 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN} +endpoints: + - host: {TEST_HOST} + port: {target_port} + path: /** + protocol: rest + access: read-write + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 +binaries: + - /** +" + )) +} + +/// What the sandbox saw calling the protected target: `SAW:`, +/// `DENIED` for a refused grant, or a failure description. +fn call_script(port: u16) -> String { + format!( + r#" +import urllib.error, urllib.request, uuid + +try: + with urllib.request.urlopen("http://{TEST_HOST}:{port}/resource", timeout=20) as response: + print(response.read().decode()) +except urllib.error.HTTPError as error: + request_id = error.headers.get("x-openshell-request-id", "") + if error.code == 502 and str(uuid.UUID(request_id)) == request_id: + print("DENIED") + else: + print(f"HTTP-{{error.code}}") +except OSError: + print("UNREACHABLE") +"# + ) +} + +async fn call(sandbox: &SandboxGuard, port: u16) -> String { + let output = sandbox + .exec(&["python3", "-c", &call_script(port)]) + .await + .unwrap_or_else(|error| panic!("call from {}: {error}", sandbox.name)); + output + .lines() + .map(str::trim) + .find(|line| { + line.starts_with("SAW:") + || *line == "DENIED" + || line.starts_with("HTTP-") + || *line == "UNREACHABLE" + }) + .unwrap_or_else(|| panic!("no call result from {}:\n{output}", sandbox.name)) + .to_string() +} + +/// Call until the sandbox sees `expected`, as SPIRE propagates a new entry to +/// its agent and a restarted sandbox instance becomes ready. +async fn call_until(sandbox: &SandboxGuard, port: u16, expected: &str) { + let mut last = String::new(); + let reached = tokio::time::timeout(Duration::from_secs(120), async { + loop { + last = call(sandbox, port).await; + if last == expected { + return; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) + .await; + assert!( + reached.is_ok(), + "{} never saw {expected}; last saw {last}", + sandbox.name + ); +} + +/// The Sandbox resource of an `OpenShell` sandbox. +async fn sandbox_resource(name: &str) -> serde_json::Value { + let output = kubectl(&[ + "get", + "sandboxes.agents.x-k8s.io", + "--all-namespaces", + "-l", + &format!("openshell.ai/sandbox-name={name}"), + "-o", + "json", + ]) + .await; + let list: serde_json::Value = serde_json::from_str(&output).expect("parse Sandbox list"); + let [resource] = list["items"].as_array().expect("Sandbox items").as_slice() else { + panic!("expected one Sandbox resource for {name}: {list}"); + }; + resource.clone() +} + +async fn sandbox_id(name: &str) -> String { + let (ok, output) = cli(&["sandbox", "get", name, "--output", "json"]).await; + assert!(ok, "sandbox get {name} failed:\n{output}"); + let details: serde_json::Value = serde_json::from_str(&output).expect("parse sandbox JSON"); + details["id"].as_str().expect("sandbox id").to_string() +} + +/// The shared-supervisor sandbox instance route of a sandbox's current +/// generation. +async fn instance_route(name: &str) -> String { + let resource = sandbox_resource(name).await; + let annotations = &resource["metadata"]["annotations"]; + assert_eq!(annotations[TOPOLOGY_ANNOTATION], "gateway"); + annotations[INSTANCE_ROUTE_ANNOTATION] + .as_str() + .expect("sandbox instance route") + .to_string() +} + +/// Gateway ID the gateway signs session JWTs with. +async fn gateway_id() -> String { + let output = kubectl(&[ + "-n", + &release_namespace(), + "get", + "configmaps", + "-o", + "jsonpath={.items[*].data.gateway\\.toml}", + ]) + .await; + output + .lines() + .find_map(|line| { + let value = line.trim().strip_prefix("gateway_id = \"")?; + value.strip_suffix('"').map(str::to_string) + }) + .expect("gateway_id in gateway TOML") +} + +/// SPIRE agent ID of the node running the gateway. +async fn gateway_agent_id() -> String { + let node = kubectl(&[ + "-n", + &release_namespace(), + "get", + "pods", + "-l", + "app.kubernetes.io/name=openshell,app.kubernetes.io/instance=openshell", + "-o", + "jsonpath={.items[0].spec.nodeName}", + ]) + .await; + let uid = kubectl(&["get", "node", node.trim(), "-o", "jsonpath={.metadata.uid}"]).await; + format!( + "spiffe://{TRUST_DOMAIN}/spire/agent/k8s_psat/{SPIRE_CLUSTER}/{}", + uid.trim() + ) +} + +fn spire_fixture() -> String { + std::env::var("OPENSHELL_E2E_SPIRE_NAMESPACE") + .ok() + .filter(|namespace| !namespace.is_empty()) + .expect("OPENSHELL_E2E_SPIRE_NAMESPACE is set by with-kube-gateway.sh") +} + +/// Register `sandbox_id`'s SPIFFE ID for delegation, scoped to `agent_id`. +async fn register(gateway_id: &str, sandbox_id: &str, agent_id: &str) { + let fixture = spire_fixture(); + // The SPIRE Controller Manager's class is `-`; the + // wrapper installs release `spire` in the fixture namespace. + let class_name = format!("{fixture}-spire"); + let manifest = format!( + r"apiVersion: spire.spiffe.io/v1alpha1 +kind: ClusterStaticEntry +metadata: + name: openshell-sandbox-{sandbox_id} + labels: + {ENTRY_LABEL}: {fixture} +spec: + className: {class_name} + spiffeID: {} + parentID: {agent_id} + selectors: + - openshell:sandbox:{gateway_id}:{sandbox_id} +", + spiffe_id(gateway_id, sandbox_id) + ); + kubectl_with_input(&["apply", "-f", "-"], Some(&manifest)).await; +} + +async fn unregister_all() { + let _ = kubectl_command() + .args([ + "delete", + "clusterstaticentries.spire.spiffe.io", + "-l", + &format!("{ENTRY_LABEL}={}", spire_fixture()), + "--ignore-not-found", + ]) + .output() + .await; +} + +// Inspect access-bearing fields. Image tags and node names may legitimately +// contain "spiffe" without exposing an identity socket or credential. +fn assert_no_spiffe_access(pod: &serde_json::Value) { + let spec = &pod["spec"]; + let containers = spec["containers"] + .as_array() + .unwrap() + .iter() + .map(|container| { + serde_json::json!({ + "env": container["env"], + "mounts": container["volumeMounts"], + "args": container["args"], + }) + }) + .collect::>(); + let access = serde_json::json!({ "volumes": spec["volumes"], "containers": containers }) + .to_string() + .to_lowercase(); + assert!( + !access.contains("spiffe") && !access.contains("spire"), + "Pod {} carries SPIFFE access", + pod["metadata"]["name"] + ); +} + +/// No sandbox workload Pod receives a SPIFFE socket, SPIRE mount, or SPIFFE +/// environment. +async fn assert_workloads_hold_no_spiffe_material() { + let output = kubectl(&[ + "get", + "pods", + "--all-namespaces", + "-l", + "openshell.ai/boundary-role=workload", + "-o", + "json", + ]) + .await; + let pods: serde_json::Value = serde_json::from_str(&output).expect("parse workload Pods"); + let items = pods["items"].as_array().expect("Pod items"); + assert!(!items.is_empty(), "no sandbox workload Pods found"); + for pod in items { + assert_no_spiffe_access(pod); + } +} + +/// Inside the sandbox: no SPIFFE socket, environment, or SVID. +async fn assert_sandbox_holds_no_spiffe_material(sandbox: &SandboxGuard) { + let script = r#" +import base64, json, os, re +leaks = [name for name in os.environ if "SPIFFE" in name.upper() or "SPIRE" in name.upper()] +leaks += [path for path in ("/spiffe-workload-api", "/run/spire", "/tmp/spire-agent") if os.path.exists(path)] +for value in os.environ.values(): + for token in re.findall(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", value): + try: + payload = token.split(".")[1] + claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + except (ValueError, UnicodeError): + continue + if str(claims.get("sub", "")).startswith("spiffe://openshell.local/"): + leaks.append("svid-in-environment") +print("LEAKS:" + ",".join(leaks) if leaks else "NO-SPIFFE") +"#; + let output = sandbox + .exec(&["python3", "-c", script]) + .await + .unwrap_or_else(|error| panic!("inspect {}: {error}", sandbox.name)); + assert!( + output.lines().any(|line| line.trim() == "NO-SPIFFE"), + "{} holds SPIFFE material:\n{output}", + sandbox.name + ); +} + +async fn create(name: &str, provider: &str) -> SandboxGuard { + SandboxGuard::create_keep_with_args( + &["--name", name, "--provider", provider], + &["sh", "-c", "echo spiffe-ready; exec sleep infinity"], + "spiffe-ready", + ) + .await + .unwrap_or_else(|error| panic!("create {name}: {error}")) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn shared_supervisor_acts_as_each_sandboxs_stable_spiffe_identity() { + let nonce = format!("{:08x}", rand::random::()); + let stored_token = format!("stored-{nonce}"); + let tokens = TokenService::start(stored_token.clone()).await; + let target = Target::start().await; + let token_endpoint = expose_token_service(tokens.port).await; + let provider = format!("e2e-spiffe-{nonce}"); + let profile = profile(&provider, &token_endpoint, target.port); + let (imported, output) = cli(&[ + "profile", + "import", + "--file", + profile.path().to_str().expect("UTF-8 profile path"), + ]) + .await; + assert!(imported, "profile import failed:\n{output}"); + let credential = format!("subject_token={stored_token}"); + let (created, output) = cli(&[ + "provider", + "create", + "--name", + &provider, + "--type", + &provider, + "--credential", + &credential, + ]) + .await; + assert!(created, "provider create failed:\n{output}"); + + // Sandbox names are limited to 19 characters. + let (alpha_name, beta_name) = (format!("spa-{nonce}"), format!("spb-{nonce}")); + let (alpha, beta) = tokio::join!( + create(&alpha_name, &provider), + create(&beta_name, &provider) + ); + let (mut alpha, mut beta) = (alpha, beta); + let gateway_id = gateway_id().await; + let agent_id = gateway_agent_id().await; + let alpha_id = sandbox_id(&alpha.name).await; + let beta_id = sandbox_id(&beta.name).await; + + // Without a registration entry the shared supervisor has no identity to + // act as, and never falls back to its own. + assert_eq!(call(&alpha, target.port).await, "DENIED"); + assert!(tokens.exchanges().is_empty()); + + // Each sandbox exchanges under its own stable SPIFFE ID. + register(&gateway_id, &alpha_id, &agent_id).await; + register(&gateway_id, &beta_id, &agent_id).await; + call_until(&alpha, target.port, &format!("SAW:{alpha_id}")).await; + call_until(&beta, target.port, &format!("SAW:{beta_id}")).await; + let finals = |exchanges: &[Exchange]| { + exchanges + .iter() + .filter(|exchange| !exchange.intermediate) + .map(|exchange| exchange.assertion_subject.clone()) + .collect::>() + }; + let exchanges = tokens.exchanges(); + assert_eq!( + finals(&exchanges), + [ + spiffe_id(&gateway_id, &alpha_id), + spiffe_id(&gateway_id, &beta_id) + ] + ); + // The gateway asked for intermediate tokens for exactly those identities, + // with its own identity rather than a sandbox's. + for exchange in exchanges.iter().filter(|exchange| exchange.intermediate) { + assert!( + [ + spiffe_id(&gateway_id, &alpha_id), + spiffe_id(&gateway_id, &beta_id) + ] + .contains(&exchange.audience), + "unexpected intermediate audience {exchange:?}" + ); + assert!( + !exchange.assertion_subject.starts_with(ID_PREFIX), + "gateway presented a sandbox identity: {exchange:?}" + ); + } + let shared_pods = kubectl(&[ + "-n", + &release_namespace(), + "get", + "pods", + "-l", + "app.kubernetes.io/component=supervisor", + "-o", + "json", + ]) + .await; + let shared: serde_json::Value = serde_json::from_str(&shared_pods).unwrap(); + for pod in shared["items"].as_array().unwrap() { + assert_no_spiffe_access(pod); + } + assert_workloads_hold_no_spiffe_material().await; + assert_sandbox_holds_no_spiffe_material(&alpha).await; + assert_sandbox_holds_no_spiffe_material(&beta).await; + + // A restart is a new generation with a new sandbox instance and an empty + // cache, and it acquires under the same SPIFFE ID. + let alpha_route = instance_route(&alpha.name).await; + let (stopped, output) = cli(&["sandbox", "stop", &alpha.name]).await; + assert!(stopped, "sandbox stop failed:\n{output}"); + let (started, output) = cli(&["sandbox", "start", &alpha.name]).await; + assert!(started, "sandbox start failed:\n{output}"); + assert_ne!(instance_route(&alpha.name).await, alpha_route); + let before_restart = finals(&tokens.exchanges()).len(); + call_until(&alpha, target.port, &format!("SAW:{alpha_id}")).await; + let after_restart = finals(&tokens.exchanges()); + assert!( + after_restart.len() > before_restart, + "the restarted generation reused an earlier generation's token" + ); + assert!( + after_restart[before_restart..] + .iter() + .all(|subject| *subject == spiffe_id(&gateway_id, &alpha_id)) + ); + assert_eq!(call(&beta, target.port).await, format!("SAW:{beta_id}")); + + // A sandbox recreated under the same name is a new sandbox with a new + // identity; the old registration does not apply to it. + alpha.cleanup().await; + let mut recreated = create(&alpha_name, &provider).await; + let recreated_id = sandbox_id(&recreated.name).await; + assert_ne!(recreated_id, alpha_id); + let before_recreate = tokens.exchanges().len(); + assert_eq!(call(&recreated, target.port).await, "DENIED"); + assert_eq!(tokens.exchanges().len(), before_recreate); + register(&gateway_id, &recreated_id, &agent_id).await; + call_until(&recreated, target.port, &format!("SAW:{recreated_id}")).await; + assert_eq!( + finals(&tokens.exchanges()).last(), + Some(&spiffe_id(&gateway_id, &recreated_id)) + ); + assert_sandbox_holds_no_spiffe_material(&recreated).await; + + // Identifiers only: the exchanges' tokens are never printed. + for exchange in tokens.exchanges() { + eprintln!( + "token exchange: intermediate={} assertion_subject={} audience={}", + exchange.intermediate, exchange.assertion_subject, exchange.audience + ); + } + + recreated.cleanup().await; + beta.cleanup().await; + let _ = cli(&["provider", "delete", &provider]).await; + let _ = cli(&["profile", "delete", &provider]).await; + unregister_all().await; + remove_token_service().await; +} diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index b13dbc380b..380e21677b 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -54,6 +54,14 @@ # installing OpenShell. This is used by HA CI so the gateway can run multiple # replicas without requiring the OpenShell chart to own a database. # +# SPIRE delegation fixture: +# Set OPENSHELL_E2E_KUBE_SPIRE_DELEGATION=1 to install pinned SPIRE hardened +# charts (ci/values-spire-delegation-stack.yaml) before OpenShell, with the +# agent admin socket exposed for the shared supervisor. The fixture needs a +# cluster without SPIRE CRDs and creates a fresh namespace +# (OPENSHELL_E2E_SPIRE_NAMESPACE, default openshell-e2e-spire); it removes +# both, and the registration entries tests label with that namespace, on exit. +# # Credential-driver fixture: # Set OPENSHELL_E2E_CREDENTIAL_DRIVERS=1 to enable one credential storage # backend. Set OPENSHELL_E2E_CREDENTIAL_DRIVER to `kubernetes-secrets` or @@ -114,6 +122,12 @@ VAULT_DEV_ROOT_TOKEN="${OPENSHELL_E2E_VAULT_DEV_ROOT_TOKEN:-root}" VAULT_CA_CONFIG_MAP="openbao-ca" VAULT_DNS_ALIAS="${VAULT_RELEASE_NAME}-0" VAULT_CA_FILE="${WORKDIR}/openbao-ca.crt" +SPIRE_FIXTURE_DEPLOYED=0 +SPIRE_NAMESPACE="${OPENSHELL_E2E_SPIRE_NAMESPACE:-openshell-e2e-spire}" +SPIRE_CRDS=(clusterspiffeids.spire.spiffe.io clusterstaticentries.spire.spiffe.io clusterfederatedtrustdomains.spire.spiffe.io) +SPIRE_CHART_REPO="https://spiffe.github.io/helm-charts-hardened/" +SPIRE_CHART_VERSION="${OPENSHELL_E2E_SPIRE_CHART_VERSION:-0.30.3}" +SPIRE_CRDS_CHART_VERSION="${OPENSHELL_E2E_SPIRE_CRDS_CHART_VERSION:-0.6.1}" CORPORATE_PROXY_FIXTURE_DEPLOYED=0 CORPORATE_PROXY_FIXTURE_SECRET="openshell-e2e-proxy-auth" CORPORATE_PROXY_FIXTURE_CA_CONFIGMAP="openshell-e2e-proxy-ca" @@ -481,6 +495,53 @@ provision_vault_auth() { ttl=1h >/dev/null } +deploy_spire_fixture() { + # The fixture owns only what it creates: refuse a cluster that already has + # the namespace or SPIRE CRDs, so cleanup can never remove another install. + local crd + for crd in "${SPIRE_CRDS[@]}"; do + if kctl get crd "${crd}" >/dev/null 2>&1; then + echo "ERROR: CRD ${crd} already exists; the SPIRE fixture needs a cluster without SPIRE." >&2 + exit 2 + fi + done + if ! kctl create namespace "${SPIRE_NAMESPACE}" >/dev/null; then + echo "ERROR: could not create a fresh SPIRE fixture namespace ${SPIRE_NAMESPACE}; set OPENSHELL_E2E_SPIRE_NAMESPACE to an unused name." >&2 + exit 2 + fi + SPIRE_FIXTURE_DEPLOYED=1 + echo "Installing SPIRE ${SPIRE_CHART_VERSION} fixture in namespace ${SPIRE_NAMESPACE}..." + helmctl install spire-crds spire-crds --repo "${SPIRE_CHART_REPO}" \ + --version "${SPIRE_CRDS_CHART_VERSION}" \ + --namespace "${SPIRE_NAMESPACE}" --wait + helmctl install spire spire --repo "${SPIRE_CHART_REPO}" \ + --version "${SPIRE_CHART_VERSION}" \ + --namespace "${SPIRE_NAMESPACE}" \ + --values "${ROOT}/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml" \ + --wait --timeout 5m + kctl -n "${SPIRE_NAMESPACE}" rollout status daemonset/spire-agent --timeout=180s + # Tests label the registration entries they create with this namespace. + export OPENSHELL_E2E_SPIRE_NAMESPACE="${SPIRE_NAMESPACE}" +} + +cleanup_spire_fixture() { + [ "${SPIRE_FIXTURE_DEPLOYED}" = "1" ] || return 0 + kctl delete clusterstaticentries.spire.spiffe.io \ + -l "openshell.ai/e2e-spire-fixture=${SPIRE_NAMESPACE}" --ignore-not-found \ + >/dev/null 2>&1 || true + helmctl uninstall spire --namespace "${SPIRE_NAMESPACE}" --wait --timeout 120s \ + >/dev/null 2>&1 || true + helmctl uninstall spire-crds --namespace "${SPIRE_NAMESPACE}" --wait --timeout 60s \ + >/dev/null 2>&1 || true + # The CRD chart keeps its CRDs on uninstall. None existed before the + # fixture, so these are the fixture's own. + kctl delete crd "${SPIRE_CRDS[@]}" --ignore-not-found --wait=false \ + >/dev/null 2>&1 || true + kctl delete namespace "${SPIRE_NAMESPACE}" --wait=true --timeout=120s \ + --ignore-not-found >/dev/null 2>&1 || true + SPIRE_FIXTURE_DEPLOYED=0 +} + cleanup_vault_fixture() { [ -n "${KUBE_CONTEXT}" ] || return 0 [ -n "${VAULT_NAMESPACE}" ] || return 0 @@ -567,6 +628,8 @@ cleanup() { cleanup_vault_fixture fi + cleanup_spire_fixture + if [ "${ENVOY_GATEWAY_CONFIG_APPLIED}" = "1" ] && [ -n "${KUBE_CONTEXT}" ]; then if command -v kubectl >/dev/null 2>&1; then kctl -n "${NAMESPACE}" delete backendtrafficpolicy.gateway.envoyproxy.io \ @@ -753,6 +816,7 @@ run_scenario() { export OPENSHELL_E2E_KUBE_CONTEXT="${KUBE_CONTEXT}" export OPENSHELL_E2E_KUBE_NAMESPACE="${NAMESPACE}" export OPENSHELL_E2E_KUBE_RELEASE="${RELEASE_NAME}" + export OPENSHELL_E2E_HOST_GATEWAY_IP="${HOST_GATEWAY_IP}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" e2e_import_example_provider_profiles \ @@ -1395,6 +1459,9 @@ if use_envoy_gateway; then helm_values_args+=(--values "${ROOT}/deploy/helm/openshell/ci/values-gateway.yaml") install_envoy_gateway fi +if [ "${OPENSHELL_E2E_KUBE_SPIRE_DELEGATION:-0}" = "1" ]; then + deploy_spire_fixture +fi if [ "${OPENSHELL_E2E_KUBE_DB_SCENARIOS:-0}" = "1" ]; then # --- Multi-scenario mode: test all database backends --- @@ -1491,6 +1558,7 @@ else export OPENSHELL_E2E_KUBE_CONTEXT="${KUBE_CONTEXT}" export OPENSHELL_E2E_KUBE_NAMESPACE="${NAMESPACE}" export OPENSHELL_E2E_KUBE_RELEASE="${RELEASE_NAME}" + export OPENSHELL_E2E_HOST_GATEWAY_IP="${HOST_GATEWAY_IP}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" e2e_import_example_provider_profiles \ diff --git a/proto/openshell.proto b/proto/openshell.proto index 44d5ed81ee..bf4963c276 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -537,6 +537,13 @@ service OpenShell { }; } + // Obtain the final provider token for the authenticated sandbox session. + // SPIFFE assertions and intermediate credentials remain in the gateway. + rpc GetSandboxProviderAccessToken(GetSandboxProviderAccessTokenRequest) + returns (GetSandboxProviderAccessTokenResponse) { + option (openshell.options.v1.authorization) = { auth_mode: "sandbox" }; + } + // Fetch recent sandbox logs (one-shot). rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse) { option (openshell.options.v1.authorization) = { @@ -2783,6 +2790,19 @@ message ProviderEnvironmentSnapshot { map files = 7; } +// Supervisor-only request. Sandbox/workspace are derived from the verified +// session JWT. No caller-selected identity, endpoint, audience, or scope. +message GetSandboxProviderAccessTokenRequest { + // Endpoint-bound dynamic credential key supplied by gateway configuration. + string credential_key = 1; +} + +message GetSandboxProviderAccessTokenResponse { + string access_token = 1 [(openshell.options.v1.secret) = true]; + google.protobuf.Duration expires_after = 2; + string token_type = 3; +} + message ExchangeProviderSubjectTokenRequest { // The sandbox ID. Must match the authenticated sandbox principal. string sandbox_id = 1; diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index f180617bf6..8e845e05a5 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -11868,6 +11868,113 @@ func (x *ProviderEnvironmentSnapshot) GetFiles() map[string]string { return nil } +// Supervisor-only request. Sandbox/workspace are derived from the verified +// session JWT. No caller-selected identity, endpoint, audience, or scope. +type GetSandboxProviderAccessTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Endpoint-bound dynamic credential key supplied by gateway configuration. + CredentialKey string `protobuf:"bytes,1,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderAccessTokenRequest) Reset() { + *x = GetSandboxProviderAccessTokenRequest{} + mi := &file_openshell_proto_msgTypes[143] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderAccessTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderAccessTokenRequest) ProtoMessage() {} + +func (x *GetSandboxProviderAccessTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[143] + 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 GetSandboxProviderAccessTokenRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderAccessTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{143} +} + +func (x *GetSandboxProviderAccessTokenRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type GetSandboxProviderAccessTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + ExpiresAfter *durationpb.Duration `protobuf:"bytes,2,opt,name=expires_after,json=expiresAfter,proto3" json:"expires_after,omitempty"` + TokenType string `protobuf:"bytes,3,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderAccessTokenResponse) Reset() { + *x = GetSandboxProviderAccessTokenResponse{} + mi := &file_openshell_proto_msgTypes[144] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderAccessTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderAccessTokenResponse) ProtoMessage() {} + +func (x *GetSandboxProviderAccessTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[144] + 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 GetSandboxProviderAccessTokenResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderAccessTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{144} +} + +func (x *GetSandboxProviderAccessTokenResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *GetSandboxProviderAccessTokenResponse) GetExpiresAfter() *durationpb.Duration { + if x != nil { + return x.ExpiresAfter + } + return nil +} + +func (x *GetSandboxProviderAccessTokenResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + type ExchangeProviderSubjectTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The sandbox ID. Must match the authenticated sandbox principal. @@ -11885,7 +11992,7 @@ type ExchangeProviderSubjectTokenRequest struct { func (x *ExchangeProviderSubjectTokenRequest) Reset() { *x = ExchangeProviderSubjectTokenRequest{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11897,7 +12004,7 @@ func (x *ExchangeProviderSubjectTokenRequest) String() string { func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11910,7 +12017,7 @@ func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { @@ -11952,7 +12059,7 @@ type ExchangeProviderSubjectTokenResponse struct { func (x *ExchangeProviderSubjectTokenResponse) Reset() { *x = ExchangeProviderSubjectTokenResponse{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11964,7 +12071,7 @@ func (x *ExchangeProviderSubjectTokenResponse) String() string { func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11977,7 +12084,7 @@ func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { @@ -12051,7 +12158,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12063,7 +12170,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12076,7 +12183,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -12173,7 +12280,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12185,7 +12292,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12198,7 +12305,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -12312,7 +12419,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12324,7 +12431,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12337,7 +12444,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *AddNetworkRule) GetRuleName() string { @@ -12365,7 +12472,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12377,7 +12484,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12390,7 +12497,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -12423,7 +12530,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12435,7 +12542,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12448,7 +12555,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -12477,7 +12584,7 @@ type L7RuleTarget struct { func (x *L7RuleTarget) Reset() { *x = L7RuleTarget{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12489,7 +12596,7 @@ func (x *L7RuleTarget) String() string { func (*L7RuleTarget) ProtoMessage() {} func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12502,7 +12609,7 @@ func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RuleTarget.ProtoReflect.Descriptor instead. func (*L7RuleTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *L7RuleTarget) GetRuleName() string { @@ -12557,7 +12664,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12569,7 +12676,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12582,7 +12689,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { @@ -12609,7 +12716,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12621,7 +12728,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12634,7 +12741,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { @@ -12661,7 +12768,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12673,7 +12780,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12686,7 +12793,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -12722,7 +12829,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12734,7 +12841,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12747,7 +12854,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -12801,7 +12908,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12813,7 +12920,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12826,7 +12933,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *GetSandboxPolicyStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -12870,7 +12977,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12882,7 +12989,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12895,7 +13002,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -12932,7 +13039,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12944,7 +13051,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12957,7 +13064,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *ListSandboxPoliciesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -13009,7 +13116,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13021,7 +13128,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13034,7 +13141,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{160} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -13068,7 +13175,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13080,7 +13187,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13093,7 +13200,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -13133,7 +13240,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13145,7 +13252,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13158,7 +13265,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{162} } type SandboxConfigurationAdmission struct { @@ -13176,7 +13283,7 @@ type SandboxConfigurationAdmission struct { func (x *SandboxConfigurationAdmission) Reset() { *x = SandboxConfigurationAdmission{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13188,7 +13295,7 @@ func (x *SandboxConfigurationAdmission) String() string { func (*SandboxConfigurationAdmission) ProtoMessage() {} func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13201,7 +13308,7 @@ func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxConfigurationAdmission.ProtoReflect.Descriptor instead. func (*SandboxConfigurationAdmission) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *SandboxConfigurationAdmission) GetInstanceId() string { @@ -13265,7 +13372,7 @@ type ReportSandboxConfigurationRequest struct { func (x *ReportSandboxConfigurationRequest) Reset() { *x = ReportSandboxConfigurationRequest{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13277,7 +13384,7 @@ func (x *ReportSandboxConfigurationRequest) String() string { func (*ReportSandboxConfigurationRequest) ProtoMessage() {} func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13290,7 +13397,7 @@ func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationRequest.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *ReportSandboxConfigurationRequest) GetSandboxId() string { @@ -13322,7 +13429,7 @@ type ReportSandboxConfigurationResponse struct { func (x *ReportSandboxConfigurationResponse) Reset() { *x = ReportSandboxConfigurationResponse{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13334,7 +13441,7 @@ func (x *ReportSandboxConfigurationResponse) String() string { func (*ReportSandboxConfigurationResponse) ProtoMessage() {} func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13347,7 +13454,7 @@ func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationResponse.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{165} } // A versioned policy revision with metadata. @@ -13380,7 +13487,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13392,7 +13499,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13405,7 +13512,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -13485,7 +13592,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13497,7 +13604,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13510,7 +13617,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *GetSandboxLogsRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -13568,7 +13675,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13580,7 +13687,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13593,7 +13700,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -13619,7 +13726,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13631,7 +13738,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13644,7 +13751,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{169} } // Get sandbox logs response. @@ -13660,7 +13767,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13672,7 +13779,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13685,7 +13792,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -13719,7 +13826,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13731,7 +13838,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13744,7 +13851,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -13852,7 +13959,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13864,7 +13971,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13877,7 +13984,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -14027,7 +14134,7 @@ type SupervisorHello struct { func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14039,7 +14146,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14052,7 +14159,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *SupervisorHello) GetSandboxId() string { @@ -14125,7 +14232,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14137,7 +14244,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14150,7 +14257,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *SessionAccepted) GetSessionId() string { @@ -14199,7 +14306,7 @@ type ConfigUpdate struct { func (x *ConfigUpdate) Reset() { *x = ConfigUpdate{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14211,7 +14318,7 @@ func (x *ConfigUpdate) String() string { func (*ConfigUpdate) ProtoMessage() {} func (x *ConfigUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14224,7 +14331,7 @@ func (x *ConfigUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigUpdate.ProtoReflect.Descriptor instead. func (*ConfigUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{175} } func (x *ConfigUpdate) GetUpdateId() string { @@ -14261,7 +14368,7 @@ type ConfigApplyFailure struct { func (x *ConfigApplyFailure) Reset() { *x = ConfigApplyFailure{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14273,7 +14380,7 @@ func (x *ConfigApplyFailure) String() string { func (*ConfigApplyFailure) ProtoMessage() {} func (x *ConfigApplyFailure) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14286,7 +14393,7 @@ func (x *ConfigApplyFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigApplyFailure.ProtoReflect.Descriptor instead. func (*ConfigApplyFailure) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *ConfigApplyFailure) GetCode() string { @@ -14330,7 +14437,7 @@ type ConfigUpdateResult struct { func (x *ConfigUpdateResult) Reset() { *x = ConfigUpdateResult{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14342,7 +14449,7 @@ func (x *ConfigUpdateResult) String() string { func (*ConfigUpdateResult) ProtoMessage() {} func (x *ConfigUpdateResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14355,7 +14462,7 @@ func (x *ConfigUpdateResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigUpdateResult.ProtoReflect.Descriptor instead. func (*ConfigUpdateResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *ConfigUpdateResult) GetUpdateId() string { @@ -14406,7 +14513,7 @@ type PolicyLoadResult struct { func (x *PolicyLoadResult) Reset() { *x = PolicyLoadResult{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14418,7 +14525,7 @@ func (x *PolicyLoadResult) String() string { func (*PolicyLoadResult) ProtoMessage() {} func (x *PolicyLoadResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14431,7 +14538,7 @@ func (x *PolicyLoadResult) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyLoadResult.ProtoReflect.Descriptor instead. func (*PolicyLoadResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *PolicyLoadResult) GetVersion() uint32 { @@ -14466,7 +14573,7 @@ type SessionRejected struct { func (x *SessionRejected) Reset() { *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14478,7 +14585,7 @@ func (x *SessionRejected) String() string { func (*SessionRejected) ProtoMessage() {} func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14491,7 +14598,7 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *SessionRejected) GetReason() string { @@ -14520,7 +14627,7 @@ type SessionRedirect struct { func (x *SessionRedirect) Reset() { *x = SessionRedirect{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14532,7 +14639,7 @@ func (x *SessionRedirect) String() string { func (*SessionRedirect) ProtoMessage() {} func (x *SessionRedirect) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14545,7 +14652,7 @@ func (x *SessionRedirect) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRedirect.ProtoReflect.Descriptor instead. func (*SessionRedirect) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{180} } func (x *SessionRedirect) GetPeerEndpoint() string { @@ -14571,7 +14678,7 @@ type SupervisorHeartbeat struct { func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14583,7 +14690,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14596,7 +14703,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{181} } // Gateway heartbeat. @@ -14608,7 +14715,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14620,7 +14727,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14633,7 +14740,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{182} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -14650,7 +14757,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14662,7 +14769,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14675,7 +14782,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -14707,7 +14814,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14719,7 +14826,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14732,7 +14839,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{184} } // Terminal-delivery completion reported after all expected foreground SSH @@ -14747,7 +14854,7 @@ type FinalizeMainProcessExitRequest struct { func (x *FinalizeMainProcessExitRequest) Reset() { *x = FinalizeMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14759,7 +14866,7 @@ func (x *FinalizeMainProcessExitRequest) String() string { func (*FinalizeMainProcessExitRequest) ProtoMessage() {} func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14772,7 +14879,7 @@ func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { @@ -14797,7 +14904,7 @@ type FinalizeMainProcessExitResponse struct { func (x *FinalizeMainProcessExitResponse) Reset() { *x = FinalizeMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14809,7 +14916,7 @@ func (x *FinalizeMainProcessExitResponse) String() string { func (*FinalizeMainProcessExitResponse) ProtoMessage() {} func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14822,7 +14929,7 @@ func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{186} } // Gateway requests the supervisor to open a relay channel. @@ -14851,7 +14958,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14863,7 +14970,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14876,7 +14983,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *RelayOpen) GetChannelId() string { @@ -14943,7 +15050,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14955,7 +15062,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14968,7 +15075,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{188} } // TCP target dialed by the supervisor from inside the sandbox. @@ -14984,7 +15091,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14996,7 +15103,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15009,7 +15116,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *TcpRelayTarget) GetHost() string { @@ -15037,7 +15144,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15049,7 +15156,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15062,7 +15169,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *RelayInit) GetChannelId() string { @@ -15089,7 +15196,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15101,7 +15208,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15114,7 +15221,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -15174,7 +15281,7 @@ type PeerRelayInit struct { func (x *PeerRelayInit) Reset() { *x = PeerRelayInit{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15186,7 +15293,7 @@ func (x *PeerRelayInit) String() string { func (*PeerRelayInit) ProtoMessage() {} func (x *PeerRelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15199,7 +15306,7 @@ func (x *PeerRelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerRelayInit.ProtoReflect.Descriptor instead. func (*PeerRelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{192} } func (x *PeerRelayInit) GetSandboxId() string { @@ -15237,7 +15344,7 @@ type PeerRelayFrame struct { func (x *PeerRelayFrame) Reset() { *x = PeerRelayFrame{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15249,7 +15356,7 @@ func (x *PeerRelayFrame) String() string { func (*PeerRelayFrame) ProtoMessage() {} func (x *PeerRelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15262,7 +15369,7 @@ func (x *PeerRelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerRelayFrame.ProtoReflect.Descriptor instead. func (*PeerRelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{193} } func (x *PeerRelayFrame) GetPayload() isPeerRelayFrame_Payload { @@ -15317,7 +15424,7 @@ type PeerConfigSandboxTarget struct { func (x *PeerConfigSandboxTarget) Reset() { *x = PeerConfigSandboxTarget{} - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[194] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15329,7 +15436,7 @@ func (x *PeerConfigSandboxTarget) String() string { func (*PeerConfigSandboxTarget) ProtoMessage() {} func (x *PeerConfigSandboxTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[194] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15342,7 +15449,7 @@ func (x *PeerConfigSandboxTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerConfigSandboxTarget.ProtoReflect.Descriptor instead. func (*PeerConfigSandboxTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{192} + return file_openshell_proto_rawDescGZIP(), []int{194} } func (x *PeerConfigSandboxTarget) GetSandboxId() string { @@ -15370,7 +15477,7 @@ type PeerConfigProviderTarget struct { func (x *PeerConfigProviderTarget) Reset() { *x = PeerConfigProviderTarget{} - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[195] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15382,7 +15489,7 @@ func (x *PeerConfigProviderTarget) String() string { func (*PeerConfigProviderTarget) ProtoMessage() {} func (x *PeerConfigProviderTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[195] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15395,7 +15502,7 @@ func (x *PeerConfigProviderTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerConfigProviderTarget.ProtoReflect.Descriptor instead. func (*PeerConfigProviderTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{193} + return file_openshell_proto_rawDescGZIP(), []int{195} } func (x *PeerConfigProviderTarget) GetWorkspace() string { @@ -15427,7 +15534,7 @@ type PeerNotifyConfigUpdateRequest struct { func (x *PeerNotifyConfigUpdateRequest) Reset() { *x = PeerNotifyConfigUpdateRequest{} - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[196] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15439,7 +15546,7 @@ func (x *PeerNotifyConfigUpdateRequest) String() string { func (*PeerNotifyConfigUpdateRequest) ProtoMessage() {} func (x *PeerNotifyConfigUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[196] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15452,7 +15559,7 @@ func (x *PeerNotifyConfigUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerNotifyConfigUpdateRequest.ProtoReflect.Descriptor instead. func (*PeerNotifyConfigUpdateRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{194} + return file_openshell_proto_rawDescGZIP(), []int{196} } func (x *PeerNotifyConfigUpdateRequest) GetScope() isPeerNotifyConfigUpdateRequest_Scope { @@ -15536,7 +15643,7 @@ type PeerNotifyConfigUpdateResponse struct { func (x *PeerNotifyConfigUpdateResponse) Reset() { *x = PeerNotifyConfigUpdateResponse{} - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[197] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15548,7 +15655,7 @@ func (x *PeerNotifyConfigUpdateResponse) String() string { func (*PeerNotifyConfigUpdateResponse) ProtoMessage() {} func (x *PeerNotifyConfigUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[197] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15561,7 +15668,7 @@ func (x *PeerNotifyConfigUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerNotifyConfigUpdateResponse.ProtoReflect.Descriptor instead. func (*PeerNotifyConfigUpdateResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{195} + return file_openshell_proto_rawDescGZIP(), []int{197} } func (x *PeerNotifyConfigUpdateResponse) GetStaleOwner() bool { @@ -15586,7 +15693,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[198] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15598,7 +15705,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[198] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15611,7 +15718,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{196} + return file_openshell_proto_rawDescGZIP(), []int{198} } func (x *RelayOpenResult) GetChannelId() string { @@ -15648,7 +15755,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[199] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15660,7 +15767,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[199] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15673,7 +15780,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{197} + return file_openshell_proto_rawDescGZIP(), []int{199} } func (x *RelayClose) GetChannelId() string { @@ -15707,7 +15814,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[200] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15719,7 +15826,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[200] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15732,7 +15839,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{198} + return file_openshell_proto_rawDescGZIP(), []int{200} } func (x *L7RequestSample) GetMethod() string { @@ -15806,7 +15913,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[201] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15818,7 +15925,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[201] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15831,7 +15938,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{199} + return file_openshell_proto_rawDescGZIP(), []int{201} } func (x *DenialSummary) GetSandboxId() string { @@ -15966,7 +16073,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[202] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15978,7 +16085,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[202] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15991,7 +16098,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{200} + return file_openshell_proto_rawDescGZIP(), []int{202} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -16024,7 +16131,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[203] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16036,7 +16143,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[203] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16049,7 +16156,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{201} + return file_openshell_proto_rawDescGZIP(), []int{203} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -16137,7 +16244,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[204] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16149,7 +16256,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[204] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16162,7 +16269,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{202} + return file_openshell_proto_rawDescGZIP(), []int{204} } func (x *PolicyChunk) GetId() string { @@ -16350,7 +16457,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[205] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16362,7 +16469,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[205] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16375,7 +16482,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{203} + return file_openshell_proto_rawDescGZIP(), []int{205} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -16434,7 +16541,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[206] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16446,7 +16553,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[206] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16459,7 +16566,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{204} + return file_openshell_proto_rawDescGZIP(), []int{206} } func (x *SubmitPolicyAnalysisRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16522,7 +16629,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[207] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16534,7 +16641,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[207] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16547,7 +16654,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{205} + return file_openshell_proto_rawDescGZIP(), []int{207} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -16592,7 +16699,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[208] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16604,7 +16711,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[208] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16617,7 +16724,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{206} + return file_openshell_proto_rawDescGZIP(), []int{208} } func (x *GetDraftPolicyRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16657,7 +16764,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[209] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16669,7 +16776,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[209] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16682,7 +16789,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{207} + return file_openshell_proto_rawDescGZIP(), []int{209} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -16733,7 +16840,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[210] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16745,7 +16852,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[210] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16758,7 +16865,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{208} + return file_openshell_proto_rawDescGZIP(), []int{210} } func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16808,7 +16915,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[211] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16820,7 +16927,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[211] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16833,7 +16940,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{209} + return file_openshell_proto_rawDescGZIP(), []int{211} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -16869,7 +16976,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[212] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16881,7 +16988,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[212] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16894,7 +17001,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{210} + return file_openshell_proto_rawDescGZIP(), []int{212} } func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16940,7 +17047,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[213] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16952,7 +17059,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[213] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16965,7 +17072,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{211} + return file_openshell_proto_rawDescGZIP(), []int{213} } // Approve all pending chunks. @@ -16979,7 +17086,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[214] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16991,7 +17098,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[214] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17004,7 +17111,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{212} + return file_openshell_proto_rawDescGZIP(), []int{214} } func (x *DraftChunkApproval) GetChunkId() string { @@ -17040,7 +17147,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[215] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17052,7 +17159,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[215] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17065,7 +17172,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{213} + return file_openshell_proto_rawDescGZIP(), []int{215} } func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17120,7 +17227,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[216] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17132,7 +17239,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[216] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17145,7 +17252,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{214} + return file_openshell_proto_rawDescGZIP(), []int{216} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -17195,7 +17302,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[217] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17207,7 +17314,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[217] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17220,7 +17327,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{215} + return file_openshell_proto_rawDescGZIP(), []int{217} } func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17266,7 +17373,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[218] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17278,7 +17385,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[218] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17291,7 +17398,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{216} + return file_openshell_proto_rawDescGZIP(), []int{218} } // Reverse an approval (remove merged rule from active policy). @@ -17311,7 +17418,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[219] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17323,7 +17430,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[219] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17336,7 +17443,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{217} + return file_openshell_proto_rawDescGZIP(), []int{219} } func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17379,7 +17486,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[220] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17391,7 +17498,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[220] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17404,7 +17511,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{218} + return file_openshell_proto_rawDescGZIP(), []int{220} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -17436,7 +17543,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[221] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17448,7 +17555,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[221] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17461,7 +17568,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{219} + return file_openshell_proto_rawDescGZIP(), []int{221} } func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17495,7 +17602,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[222] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17507,7 +17614,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[222] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17520,7 +17627,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{220} + return file_openshell_proto_rawDescGZIP(), []int{222} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -17542,7 +17649,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[223] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17554,7 +17661,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[223] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17567,7 +17674,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{221} + return file_openshell_proto_rawDescGZIP(), []int{223} } func (x *GetDraftHistoryRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17601,7 +17708,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[224] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17613,7 +17720,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[224] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17626,7 +17733,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{222} + return file_openshell_proto_rawDescGZIP(), []int{224} } func (x *DraftHistoryEntry) GetEventTime() *timestamppb.Timestamp { @@ -17667,7 +17774,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[225] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17679,7 +17786,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[225] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17692,7 +17799,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{223} + return file_openshell_proto_rawDescGZIP(), []int{225} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -17717,7 +17824,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[226] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17729,7 +17836,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[226] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17742,7 +17849,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{224} + return file_openshell_proto_rawDescGZIP(), []int{226} } func (x *CreateWorkspaceRequest) GetName() string { @@ -17776,7 +17883,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[227] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17788,7 +17895,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[227] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17801,7 +17908,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{225} + return file_openshell_proto_rawDescGZIP(), []int{227} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -17822,7 +17929,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[228] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17834,7 +17941,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[228] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17847,7 +17954,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{226} + return file_openshell_proto_rawDescGZIP(), []int{228} } func (x *GetWorkspaceRequest) GetName() string { @@ -17867,7 +17974,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[227] + mi := &file_openshell_proto_msgTypes[229] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17879,7 +17986,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[227] + mi := &file_openshell_proto_msgTypes[229] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17892,7 +17999,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{227} + return file_openshell_proto_rawDescGZIP(), []int{229} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -17919,7 +18026,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[228] + mi := &file_openshell_proto_msgTypes[230] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17931,7 +18038,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[228] + mi := &file_openshell_proto_msgTypes[230] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17944,7 +18051,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{228} + return file_openshell_proto_rawDescGZIP(), []int{230} } func (x *ListWorkspacesRequest) GetPageSize() int32 { @@ -17980,7 +18087,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[229] + mi := &file_openshell_proto_msgTypes[231] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17992,7 +18099,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[229] + mi := &file_openshell_proto_msgTypes[231] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18005,7 +18112,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{229} + return file_openshell_proto_rawDescGZIP(), []int{231} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -18036,7 +18143,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[230] + mi := &file_openshell_proto_msgTypes[232] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18048,7 +18155,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[230] + mi := &file_openshell_proto_msgTypes[232] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18061,7 +18168,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{230} + return file_openshell_proto_rawDescGZIP(), []int{232} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -18095,7 +18202,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[231] + mi := &file_openshell_proto_msgTypes[233] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18107,7 +18214,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[231] + mi := &file_openshell_proto_msgTypes[233] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18120,7 +18227,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{231} + return file_openshell_proto_rawDescGZIP(), []int{233} } func (x *DeleteWorkspaceResponse) GetOutcome() DeletionOutcome { @@ -18144,7 +18251,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[232] + mi := &file_openshell_proto_msgTypes[234] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18156,7 +18263,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[232] + mi := &file_openshell_proto_msgTypes[234] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18169,7 +18276,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{232} + return file_openshell_proto_rawDescGZIP(), []int{234} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -18210,7 +18317,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[233] + mi := &file_openshell_proto_msgTypes[235] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18222,7 +18329,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[233] + mi := &file_openshell_proto_msgTypes[235] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18235,7 +18342,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{233} + return file_openshell_proto_rawDescGZIP(), []int{235} } func (x *AddWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -18276,7 +18383,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[234] + mi := &file_openshell_proto_msgTypes[236] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18288,7 +18395,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[234] + mi := &file_openshell_proto_msgTypes[236] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18301,7 +18408,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{234} + return file_openshell_proto_rawDescGZIP(), []int{236} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -18327,7 +18434,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[235] + mi := &file_openshell_proto_msgTypes[237] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18339,7 +18446,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[235] + mi := &file_openshell_proto_msgTypes[237] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18352,7 +18459,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{235} + return file_openshell_proto_rawDescGZIP(), []int{237} } func (x *RemoveWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -18393,7 +18500,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[236] + mi := &file_openshell_proto_msgTypes[238] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18405,7 +18512,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[236] + mi := &file_openshell_proto_msgTypes[238] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18418,7 +18525,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{236} + return file_openshell_proto_rawDescGZIP(), []int{238} } func (x *RemoveWorkspaceMemberResponse) GetOutcome() DeletionOutcome { @@ -18445,7 +18552,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[237] + mi := &file_openshell_proto_msgTypes[239] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18457,7 +18564,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[237] + mi := &file_openshell_proto_msgTypes[239] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18470,7 +18577,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{237} + return file_openshell_proto_rawDescGZIP(), []int{239} } func (x *ListWorkspaceMembersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -18506,7 +18613,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[238] + mi := &file_openshell_proto_msgTypes[240] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18518,7 +18625,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[238] + mi := &file_openshell_proto_msgTypes[240] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18531,7 +18638,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{238} + return file_openshell_proto_rawDescGZIP(), []int{240} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -18566,7 +18673,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[239] + mi := &file_openshell_proto_msgTypes[241] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18578,7 +18685,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[239] + mi := &file_openshell_proto_msgTypes[241] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18591,7 +18698,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{239} + return file_openshell_proto_rawDescGZIP(), []int{241} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -18631,7 +18738,7 @@ type EndpointObservation struct { func (x *EndpointObservation) Reset() { *x = EndpointObservation{} - mi := &file_openshell_proto_msgTypes[240] + mi := &file_openshell_proto_msgTypes[242] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18643,7 +18750,7 @@ func (x *EndpointObservation) String() string { func (*EndpointObservation) ProtoMessage() {} func (x *EndpointObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[240] + mi := &file_openshell_proto_msgTypes[242] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18656,7 +18763,7 @@ func (x *EndpointObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointObservation.ProtoReflect.Descriptor instead. func (*EndpointObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{240} + return file_openshell_proto_rawDescGZIP(), []int{242} } func (x *EndpointObservation) GetEndpointId() string { @@ -18709,7 +18816,7 @@ type ReportEndpointStatusRequest struct { func (x *ReportEndpointStatusRequest) Reset() { *x = ReportEndpointStatusRequest{} - mi := &file_openshell_proto_msgTypes[241] + mi := &file_openshell_proto_msgTypes[243] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18721,7 +18828,7 @@ func (x *ReportEndpointStatusRequest) String() string { func (*ReportEndpointStatusRequest) ProtoMessage() {} func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[241] + mi := &file_openshell_proto_msgTypes[243] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18734,7 +18841,7 @@ func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusRequest.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{241} + return file_openshell_proto_rawDescGZIP(), []int{243} } func (x *ReportEndpointStatusRequest) GetSandboxId() string { @@ -18803,7 +18910,7 @@ type TokenGrantStatus struct { func (x *TokenGrantStatus) Reset() { *x = TokenGrantStatus{} - mi := &file_openshell_proto_msgTypes[242] + mi := &file_openshell_proto_msgTypes[244] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18815,7 +18922,7 @@ func (x *TokenGrantStatus) String() string { func (*TokenGrantStatus) ProtoMessage() {} func (x *TokenGrantStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[242] + mi := &file_openshell_proto_msgTypes[244] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18828,7 +18935,7 @@ func (x *TokenGrantStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenGrantStatus.ProtoReflect.Descriptor instead. func (*TokenGrantStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{242} + return file_openshell_proto_rawDescGZIP(), []int{244} } func (x *TokenGrantStatus) GetLastFailure() *TokenGrantFailureObservation { @@ -18855,7 +18962,7 @@ type TokenGrantFailureObservation struct { func (x *TokenGrantFailureObservation) Reset() { *x = TokenGrantFailureObservation{} - mi := &file_openshell_proto_msgTypes[243] + mi := &file_openshell_proto_msgTypes[245] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18867,7 +18974,7 @@ func (x *TokenGrantFailureObservation) String() string { func (*TokenGrantFailureObservation) ProtoMessage() {} func (x *TokenGrantFailureObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[243] + mi := &file_openshell_proto_msgTypes[245] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18880,7 +18987,7 @@ func (x *TokenGrantFailureObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenGrantFailureObservation.ProtoReflect.Descriptor instead. func (*TokenGrantFailureObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{243} + return file_openshell_proto_rawDescGZIP(), []int{245} } func (x *TokenGrantFailureObservation) GetDestinationHost() string { @@ -18913,7 +19020,7 @@ type ReportEndpointStatusResponse struct { func (x *ReportEndpointStatusResponse) Reset() { *x = ReportEndpointStatusResponse{} - mi := &file_openshell_proto_msgTypes[244] + mi := &file_openshell_proto_msgTypes[246] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18925,7 +19032,7 @@ func (x *ReportEndpointStatusResponse) String() string { func (*ReportEndpointStatusResponse) ProtoMessage() {} func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[244] + mi := &file_openshell_proto_msgTypes[246] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18938,7 +19045,7 @@ func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusResponse.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{244} + return file_openshell_proto_rawDescGZIP(), []int{246} } // A configured endpoint and its last accepted network result in one record. @@ -18972,7 +19079,7 @@ type EndpointStatus struct { func (x *EndpointStatus) Reset() { *x = EndpointStatus{} - mi := &file_openshell_proto_msgTypes[245] + mi := &file_openshell_proto_msgTypes[247] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18984,7 +19091,7 @@ func (x *EndpointStatus) String() string { func (*EndpointStatus) ProtoMessage() {} func (x *EndpointStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[245] + mi := &file_openshell_proto_msgTypes[247] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18997,7 +19104,7 @@ func (x *EndpointStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointStatus.ProtoReflect.Descriptor instead. func (*EndpointStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{245} + return file_openshell_proto_rawDescGZIP(), []int{247} } func (x *EndpointStatus) GetEndpointId() string { @@ -19092,7 +19199,7 @@ type SandboxProvisioning struct { func (x *SandboxProvisioning) Reset() { *x = SandboxProvisioning{} - mi := &file_openshell_proto_msgTypes[246] + mi := &file_openshell_proto_msgTypes[248] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -19104,7 +19211,7 @@ func (x *SandboxProvisioning) String() string { func (*SandboxProvisioning) ProtoMessage() {} func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[246] + mi := &file_openshell_proto_msgTypes[248] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -19117,7 +19224,7 @@ func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxProvisioning.ProtoReflect.Descriptor instead. func (*SandboxProvisioning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{246} + return file_openshell_proto_rawDescGZIP(), []int{248} } func (x *SandboxProvisioning) GetAttemptId() string { @@ -19240,7 +19347,7 @@ type SandboxServiceExposure struct { func (x *SandboxServiceExposure) Reset() { *x = SandboxServiceExposure{} - mi := &file_openshell_proto_msgTypes[247] + mi := &file_openshell_proto_msgTypes[249] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -19252,7 +19359,7 @@ func (x *SandboxServiceExposure) String() string { func (*SandboxServiceExposure) ProtoMessage() {} func (x *SandboxServiceExposure) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[247] + mi := &file_openshell_proto_msgTypes[249] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -19265,7 +19372,7 @@ func (x *SandboxServiceExposure) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxServiceExposure.ProtoReflect.Descriptor instead. func (*SandboxServiceExposure) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{247} + return file_openshell_proto_rawDescGZIP(), []int{249} } func (x *SandboxServiceExposure) GetService() string { @@ -19319,7 +19426,7 @@ type EndpointFailure struct { func (x *EndpointFailure) Reset() { *x = EndpointFailure{} - mi := &file_openshell_proto_msgTypes[248] + mi := &file_openshell_proto_msgTypes[250] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -19331,7 +19438,7 @@ func (x *EndpointFailure) String() string { func (*EndpointFailure) ProtoMessage() {} func (x *EndpointFailure) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[248] + mi := &file_openshell_proto_msgTypes[250] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -19344,7 +19451,7 @@ func (x *EndpointFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointFailure.ProtoReflect.Descriptor instead. func (*EndpointFailure) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{248} + return file_openshell_proto_rawDescGZIP(), []int{250} } func (x *EndpointFailure) GetDomain() EndpointFailureDomain { @@ -20276,7 +20383,14 @@ const file_openshell_proto_rawDesc = "" + "\n" + "FilesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbd\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"M\n" + + "$GetSandboxProviderAccessTokenRequest\x12%\n" + + "\x0ecredential_key\x18\x01 \x01(\tR\rcredentialKey\"\xaf\x01\n" + + "%GetSandboxProviderAccessTokenResponse\x12'\n" + + "\faccess_token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\vaccessToken\x12>\n" + + "\rexpires_after\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\fexpiresAfter\x12\x1d\n" + + "\n" + + "token_type\x18\x03 \x01(\tR\ttokenType\"\xbd\x01\n" + "#ExchangeProviderSubjectTokenRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + @@ -21008,7 +21122,7 @@ const file_openshell_proto_rawDesc = "" + "-SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH\x10\x02*i\n" + "\x15EndpointFailureDomain\x12'\n" + "#ENDPOINT_FAILURE_DOMAIN_UNSPECIFIED\x10\x00\x12'\n" + - "#ENDPOINT_FAILURE_DOMAIN_TOKEN_GRANT\x10\x012\xb6W\n" + + "#ENDPOINT_FAILURE_DOMAIN_TOKEN_GRANT\x10\x012\xd0X\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -21121,6 +21235,8 @@ const file_openshell_proto_rawDesc = "" + "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12\x94\x01\n" + "\x1cExchangeProviderSubjectToken\x121.openshell.v1.ExchangeProviderSubjectTokenRequest\x1a2.openshell.v1.ExchangeProviderSubjectTokenResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12\x97\x01\n" + + "\x1dGetSandboxProviderAccessToken\x122.openshell.v1.GetSandboxProviderAccessTokenRequest\x1a3.openshell.v1.GetSandboxProviderAccessTokenResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12}\n" + "\x0eGetSandboxLogs\x12#.openshell.v1.GetSandboxLogsRequest\x1a$.openshell.v1.GetSandboxLogsResponse\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read\x12o\n" + @@ -21201,7 +21317,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 22) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 275) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 277) var file_openshell_proto_goTypes = []any{ (ExtensionKind)(0), // 0: openshell.v1.ExtensionKind (SandboxPhase)(0), // 1: openshell.v1.SandboxPhase @@ -21368,164 +21484,166 @@ var file_openshell_proto_goTypes = []any{ (*GetSandboxProviderEnvironmentResponse)(nil), // 162: openshell.v1.GetSandboxProviderEnvironmentResponse (*ProviderEnvironmentValue)(nil), // 163: openshell.v1.ProviderEnvironmentValue (*ProviderEnvironmentSnapshot)(nil), // 164: openshell.v1.ProviderEnvironmentSnapshot - (*ExchangeProviderSubjectTokenRequest)(nil), // 165: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 166: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 167: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 168: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 169: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 170: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 171: openshell.v1.RemoveNetworkRule - (*L7RuleTarget)(nil), // 172: openshell.v1.L7RuleTarget - (*AddDenyRules)(nil), // 173: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 174: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 175: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 176: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 177: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 178: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 179: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 180: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 181: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 182: openshell.v1.ReportPolicyStatusResponse - (*SandboxConfigurationAdmission)(nil), // 183: openshell.v1.SandboxConfigurationAdmission - (*ReportSandboxConfigurationRequest)(nil), // 184: openshell.v1.ReportSandboxConfigurationRequest - (*ReportSandboxConfigurationResponse)(nil), // 185: openshell.v1.ReportSandboxConfigurationResponse - (*SandboxPolicyRevision)(nil), // 186: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 187: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 188: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 189: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 190: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 191: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 192: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 193: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 194: openshell.v1.SessionAccepted - (*ConfigUpdate)(nil), // 195: openshell.v1.ConfigUpdate - (*ConfigApplyFailure)(nil), // 196: openshell.v1.ConfigApplyFailure - (*ConfigUpdateResult)(nil), // 197: openshell.v1.ConfigUpdateResult - (*PolicyLoadResult)(nil), // 198: openshell.v1.PolicyLoadResult - (*SessionRejected)(nil), // 199: openshell.v1.SessionRejected - (*SessionRedirect)(nil), // 200: openshell.v1.SessionRedirect - (*SupervisorHeartbeat)(nil), // 201: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 202: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 203: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 204: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 205: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 206: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 207: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 208: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 209: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 210: openshell.v1.RelayInit - (*RelayFrame)(nil), // 211: openshell.v1.RelayFrame - (*PeerRelayInit)(nil), // 212: openshell.v1.PeerRelayInit - (*PeerRelayFrame)(nil), // 213: openshell.v1.PeerRelayFrame - (*PeerConfigSandboxTarget)(nil), // 214: openshell.v1.PeerConfigSandboxTarget - (*PeerConfigProviderTarget)(nil), // 215: openshell.v1.PeerConfigProviderTarget - (*PeerNotifyConfigUpdateRequest)(nil), // 216: openshell.v1.PeerNotifyConfigUpdateRequest - (*PeerNotifyConfigUpdateResponse)(nil), // 217: openshell.v1.PeerNotifyConfigUpdateResponse - (*RelayOpenResult)(nil), // 218: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 219: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 220: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 221: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 222: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 223: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 224: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 225: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 226: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 227: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 228: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 229: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 230: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 231: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 232: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 233: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 234: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 235: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 236: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 237: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 238: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 239: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 240: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 241: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 242: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 243: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 244: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 245: openshell.v1.GetDraftHistoryResponse - (*CreateWorkspaceRequest)(nil), // 246: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 247: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 248: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 249: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 250: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 251: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 252: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 253: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 254: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 255: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 256: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 257: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 258: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 259: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 260: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 261: openshell.v1.ExtensionServiceCredential - (*EndpointObservation)(nil), // 262: openshell.v1.EndpointObservation - (*ReportEndpointStatusRequest)(nil), // 263: openshell.v1.ReportEndpointStatusRequest - (*TokenGrantStatus)(nil), // 264: openshell.v1.TokenGrantStatus - (*TokenGrantFailureObservation)(nil), // 265: openshell.v1.TokenGrantFailureObservation - (*ReportEndpointStatusResponse)(nil), // 266: openshell.v1.ReportEndpointStatusResponse - (*EndpointStatus)(nil), // 267: openshell.v1.EndpointStatus - (*SandboxProvisioning)(nil), // 268: openshell.v1.SandboxProvisioning - (*SandboxServiceExposure)(nil), // 269: openshell.v1.SandboxServiceExposure - (*EndpointFailure)(nil), // 270: openshell.v1.EndpointFailure - nil, // 271: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 272: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 273: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 274: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 275: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - nil, // 276: openshell.v1.PlatformEvent.MetadataEntry - nil, // 277: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 278: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 279: openshell.v1.SandboxResponse.ServiceUrlsEntry - nil, // 280: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 281: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 282: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry - nil, // 283: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - nil, // 284: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 285: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 286: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 287: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - nil, // 288: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 289: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 290: openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry - nil, // 291: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry - nil, // 292: openshell.v1.ProviderEnvironmentSnapshot.FilesEntry - nil, // 293: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 294: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 295: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 296: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*timestamppb.Timestamp)(nil), // 297: google.protobuf.Timestamp - (*datamodelv1.ObjectMeta)(nil), // 298: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 299: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 300: google.protobuf.Struct - (*durationpb.Duration)(nil), // 301: google.protobuf.Duration - (*datamodelv1.WorkspaceSelector)(nil), // 302: openshell.datamodel.v1.WorkspaceSelector - (*datamodelv1.Provider)(nil), // 303: openshell.datamodel.v1.Provider - (sandboxv1.PolicySource)(0), // 304: openshell.sandbox.v1.PolicySource - (*sandboxv1.NetworkEndpoint)(nil), // 305: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 306: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 307: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 308: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 309: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 310: openshell.sandbox.v1.L7Rule - (*sandboxv1.SandboxConfigSnapshot)(nil), // 311: openshell.sandbox.v1.SandboxConfigSnapshot - (*datamodelv1.Workspace)(nil), // 312: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 313: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 314: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 315: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 316: openshell.sandbox.v1.GetGatewayConfigResponse + (*GetSandboxProviderAccessTokenRequest)(nil), // 165: openshell.v1.GetSandboxProviderAccessTokenRequest + (*GetSandboxProviderAccessTokenResponse)(nil), // 166: openshell.v1.GetSandboxProviderAccessTokenResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 167: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 168: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 169: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 170: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 171: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 172: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 173: openshell.v1.RemoveNetworkRule + (*L7RuleTarget)(nil), // 174: openshell.v1.L7RuleTarget + (*AddDenyRules)(nil), // 175: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 176: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 177: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 178: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 179: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 180: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 181: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 182: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 183: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 184: openshell.v1.ReportPolicyStatusResponse + (*SandboxConfigurationAdmission)(nil), // 185: openshell.v1.SandboxConfigurationAdmission + (*ReportSandboxConfigurationRequest)(nil), // 186: openshell.v1.ReportSandboxConfigurationRequest + (*ReportSandboxConfigurationResponse)(nil), // 187: openshell.v1.ReportSandboxConfigurationResponse + (*SandboxPolicyRevision)(nil), // 188: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 189: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 190: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 191: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 192: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 193: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 194: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 195: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 196: openshell.v1.SessionAccepted + (*ConfigUpdate)(nil), // 197: openshell.v1.ConfigUpdate + (*ConfigApplyFailure)(nil), // 198: openshell.v1.ConfigApplyFailure + (*ConfigUpdateResult)(nil), // 199: openshell.v1.ConfigUpdateResult + (*PolicyLoadResult)(nil), // 200: openshell.v1.PolicyLoadResult + (*SessionRejected)(nil), // 201: openshell.v1.SessionRejected + (*SessionRedirect)(nil), // 202: openshell.v1.SessionRedirect + (*SupervisorHeartbeat)(nil), // 203: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 204: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 205: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 206: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 207: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 208: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 209: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 210: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 211: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 212: openshell.v1.RelayInit + (*RelayFrame)(nil), // 213: openshell.v1.RelayFrame + (*PeerRelayInit)(nil), // 214: openshell.v1.PeerRelayInit + (*PeerRelayFrame)(nil), // 215: openshell.v1.PeerRelayFrame + (*PeerConfigSandboxTarget)(nil), // 216: openshell.v1.PeerConfigSandboxTarget + (*PeerConfigProviderTarget)(nil), // 217: openshell.v1.PeerConfigProviderTarget + (*PeerNotifyConfigUpdateRequest)(nil), // 218: openshell.v1.PeerNotifyConfigUpdateRequest + (*PeerNotifyConfigUpdateResponse)(nil), // 219: openshell.v1.PeerNotifyConfigUpdateResponse + (*RelayOpenResult)(nil), // 220: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 221: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 222: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 223: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 224: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 225: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 226: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 227: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 228: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 229: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 230: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 231: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 232: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 233: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 234: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 235: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 236: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 237: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 238: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 239: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 240: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 241: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 242: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 243: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 244: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 245: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 246: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 247: openshell.v1.GetDraftHistoryResponse + (*CreateWorkspaceRequest)(nil), // 248: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 249: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 250: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 251: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 252: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 253: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 254: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 255: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 256: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 257: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 258: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 259: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 260: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 261: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 262: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 263: openshell.v1.ExtensionServiceCredential + (*EndpointObservation)(nil), // 264: openshell.v1.EndpointObservation + (*ReportEndpointStatusRequest)(nil), // 265: openshell.v1.ReportEndpointStatusRequest + (*TokenGrantStatus)(nil), // 266: openshell.v1.TokenGrantStatus + (*TokenGrantFailureObservation)(nil), // 267: openshell.v1.TokenGrantFailureObservation + (*ReportEndpointStatusResponse)(nil), // 268: openshell.v1.ReportEndpointStatusResponse + (*EndpointStatus)(nil), // 269: openshell.v1.EndpointStatus + (*SandboxProvisioning)(nil), // 270: openshell.v1.SandboxProvisioning + (*SandboxServiceExposure)(nil), // 271: openshell.v1.SandboxServiceExposure + (*EndpointFailure)(nil), // 272: openshell.v1.EndpointFailure + nil, // 273: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 274: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 275: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 276: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 277: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 278: openshell.v1.PlatformEvent.MetadataEntry + nil, // 279: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 280: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 281: openshell.v1.SandboxResponse.ServiceUrlsEntry + nil, // 282: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 283: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 284: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry + nil, // 285: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + nil, // 286: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 287: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 288: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 289: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + nil, // 290: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 291: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 292: openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry + nil, // 293: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry + nil, // 294: openshell.v1.ProviderEnvironmentSnapshot.FilesEntry + nil, // 295: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 296: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 297: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 298: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*timestamppb.Timestamp)(nil), // 299: google.protobuf.Timestamp + (*datamodelv1.ObjectMeta)(nil), // 300: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 301: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 302: google.protobuf.Struct + (*durationpb.Duration)(nil), // 303: google.protobuf.Duration + (*datamodelv1.WorkspaceSelector)(nil), // 304: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 305: openshell.datamodel.v1.Provider + (sandboxv1.PolicySource)(0), // 306: openshell.sandbox.v1.PolicySource + (*sandboxv1.NetworkEndpoint)(nil), // 307: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 308: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 309: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 310: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 311: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 312: openshell.sandbox.v1.L7Rule + (*sandboxv1.SandboxConfigSnapshot)(nil), // 313: openshell.sandbox.v1.SandboxConfigSnapshot + (*datamodelv1.Workspace)(nil), // 314: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 315: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 316: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 317: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 318: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 297, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 297, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 261, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 297, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp + 299, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 299, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 263, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 299, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp 14, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus 14, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus 33, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo @@ -21536,524 +21654,527 @@ var file_openshell_proto_depIdxs = []int32{ 36, // 11: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities 37, // 12: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities 38, // 13: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 298, // 14: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 300, // 14: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 40, // 15: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec 51, // 16: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus 50, // 17: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 271, // 18: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 273, // 18: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry 43, // 19: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 299, // 20: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 301, // 20: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy 41, // 21: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements 17, // 22: openshell.v1.SandboxSpec.restart_policy:type_name -> openshell.v1.SandboxRestartPolicy 42, // 23: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 272, // 24: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 273, // 25: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 274, // 26: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 300, // 27: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 300, // 28: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 298, // 29: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 274, // 24: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 275, // 25: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 276, // 26: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 302, // 27: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 302, // 28: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 300, // 29: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 45, // 30: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec 46, // 31: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 300, // 32: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 302, // 32: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct 48, // 33: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 275, // 34: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 277, // 34: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry 47, // 35: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources 42, // 36: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements 49, // 37: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 301, // 38: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 303, // 38: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration 52, // 39: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 1, // 40: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 267, // 41: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus - 183, // 42: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 268, // 43: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning - 297, // 44: openshell.v1.SandboxStatus.next_restart_time:type_name -> google.protobuf.Timestamp - 297, // 45: openshell.v1.SandboxStatus.main_process_started_time:type_name -> google.protobuf.Timestamp - 265, // 46: openshell.v1.SandboxStatus.last_token_grant_failure:type_name -> openshell.v1.TokenGrantFailureObservation - 297, // 47: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp - 297, // 48: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp - 276, // 49: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 302, // 50: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 269, // 41: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus + 185, // 42: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 270, // 43: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning + 299, // 44: openshell.v1.SandboxStatus.next_restart_time:type_name -> google.protobuf.Timestamp + 299, // 45: openshell.v1.SandboxStatus.main_process_started_time:type_name -> google.protobuf.Timestamp + 267, // 46: openshell.v1.SandboxStatus.last_token_grant_failure:type_name -> openshell.v1.TokenGrantFailureObservation + 299, // 47: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp + 299, // 48: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp + 278, // 49: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 304, // 50: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 40, // 51: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 277, // 52: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 278, // 53: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 269, // 54: openshell.v1.CreateSandboxRequest.service_exposures:type_name -> openshell.v1.SandboxServiceExposure - 302, // 55: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 279, // 52: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 280, // 53: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 271, // 54: openshell.v1.CreateSandboxRequest.service_exposures:type_name -> openshell.v1.SandboxServiceExposure + 304, // 55: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 44, // 56: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 302, // 57: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 58: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 59: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 57: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 58: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 59: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 44, // 60: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate 44, // 61: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate 18, // 62: openshell.v1.DeleteSandboxTemplateResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 302, // 63: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 297, // 64: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp - 302, // 65: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 66: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 67: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 68: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 69: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 70: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 71: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 72: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 63: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 64: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp + 304, // 65: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 66: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 67: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 68: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 69: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 70: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 71: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 72: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 39, // 73: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 279, // 74: openshell.v1.SandboxResponse.service_urls:type_name -> openshell.v1.SandboxResponse.ServiceUrlsEntry + 281, // 74: openshell.v1.SandboxResponse.service_urls:type_name -> openshell.v1.SandboxResponse.ServiceUrlsEntry 39, // 75: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 303, // 76: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 305, // 76: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 39, // 77: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 81, // 78: openshell.v1.AttachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt 39, // 79: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 81, // 80: openshell.v1.DetachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt 79, // 81: openshell.v1.ConfigSnapshotRevision.sandbox_config:type_name -> openshell.v1.SandboxConfigRevision 77, // 82: openshell.v1.ConfigSnapshotRevision.provider_target:type_name -> openshell.v1.ProviderDesiredIdentity - 304, // 83: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 306, // 83: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource 5, // 84: openshell.v1.ConfigUpdateOperation.component:type_name -> openshell.v1.ConfigComponent 78, // 85: openshell.v1.ConfigUpdateOperation.target_revision:type_name -> openshell.v1.ConfigSnapshotRevision 7, // 86: openshell.v1.ConfigUpdateOperation.state:type_name -> openshell.v1.ConfigUpdateOperationState 6, // 87: openshell.v1.ConfigUpdateOperation.outcome:type_name -> openshell.v1.ConfigApplyOutcome - 297, // 88: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp - 297, // 89: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp - 297, // 90: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp + 299, // 88: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp + 299, // 89: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp + 299, // 90: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp 2, // 91: openshell.v1.ProviderMutationReceipt.kind:type_name -> openshell.v1.ProviderMutationKind 77, // 92: openshell.v1.ProviderMutationReceipt.desired:type_name -> openshell.v1.ProviderDesiredIdentity - 297, // 93: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp + 299, // 93: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp 4, // 94: openshell.v1.ProviderReadinessObservation.reason:type_name -> openshell.v1.ProviderReadinessReason 81, // 95: openshell.v1.ProviderReadinessStatus.receipt:type_name -> openshell.v1.ProviderMutationReceipt 3, // 96: openshell.v1.ProviderReadinessStatus.state:type_name -> openshell.v1.ProviderReadinessState 4, // 97: openshell.v1.ProviderReadinessStatus.reason:type_name -> openshell.v1.ProviderReadinessReason 82, // 98: openshell.v1.ProviderReadinessStatus.observed:type_name -> openshell.v1.ProviderReadinessObservation - 297, // 99: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp - 297, // 100: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp + 299, // 99: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp + 299, // 100: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp 80, // 101: openshell.v1.ProviderReadinessStatus.operation:type_name -> openshell.v1.ConfigUpdateOperation - 302, // 102: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 102: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 83, // 103: openshell.v1.GetSandboxProviderStatusResponse.status:type_name -> openshell.v1.ProviderReadinessStatus 82, // 104: openshell.v1.ReportProviderReadinessRequest.observation:type_name -> openshell.v1.ProviderReadinessObservation - 301, // 105: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration - 301, // 106: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration + 303, // 105: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration + 303, // 106: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration 18, // 107: openshell.v1.DeleteSandboxResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 302, // 108: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 297, // 109: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp - 302, // 110: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 108: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 109: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp + 304, // 110: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 20, // 111: openshell.v1.ExposeServiceRequest.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode - 302, // 112: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 113: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 112: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 113: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 98, // 114: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 302, // 115: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 115: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 18, // 116: openshell.v1.DeleteServiceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 298, // 117: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 300, // 117: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 20, // 118: openshell.v1.ServiceEndpoint.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode 97, // 119: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint 18, // 120: openshell.v1.RevokeSshSessionResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 302, // 121: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 280, // 122: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 301, // 123: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration + 304, // 121: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 282, // 122: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 303, // 123: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration 102, // 124: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout 103, // 125: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr 104, // 126: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 208, // 127: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 209, // 128: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 210, // 127: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 211, // 128: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget 106, // 129: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit 101, // 130: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest 109, // 131: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 298, // 132: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 297, // 133: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp - 302, // 134: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 297, // 135: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp + 300, // 132: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 299, // 133: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp + 304, // 134: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 135: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp 39, // 136: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox 113, // 137: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine 53, // 138: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent 114, // 139: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 225, // 140: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 297, // 141: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp - 281, // 142: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 302, // 143: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 303, // 144: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 302, // 145: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 146: openshell.v1.GetProviderCredentialsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 301, // 147: openshell.v1.GetProviderCredentialsRequest.minimum_remaining_lifetime:type_name -> google.protobuf.Duration - 297, // 148: openshell.v1.ProviderCredentialValue.expiration_time:type_name -> google.protobuf.Timestamp - 282, // 149: openshell.v1.GetProviderCredentialsResponse.credentials:type_name -> openshell.v1.GetProviderCredentialsResponse.CredentialsEntry - 302, // 150: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 151: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 303, // 152: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 283, // 153: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - 302, // 154: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 303, // 155: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 227, // 140: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 299, // 141: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp + 283, // 142: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 304, // 143: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 305, // 144: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 304, // 145: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 146: openshell.v1.GetProviderCredentialsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 303, // 147: openshell.v1.GetProviderCredentialsRequest.minimum_remaining_lifetime:type_name -> google.protobuf.Duration + 299, // 148: openshell.v1.ProviderCredentialValue.expiration_time:type_name -> google.protobuf.Timestamp + 284, // 149: openshell.v1.GetProviderCredentialsResponse.credentials:type_name -> openshell.v1.GetProviderCredentialsResponse.CredentialsEntry + 304, // 150: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 151: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 305, // 152: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 285, // 153: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + 304, // 154: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 305, // 155: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider 81, // 156: openshell.v1.ProviderResponse.target_receipts:type_name -> openshell.v1.ProviderMutationReceipt - 303, // 157: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 302, // 158: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 159: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 305, // 157: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 304, // 158: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 159: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 146, // 160: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 301, // 161: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration + 303, // 161: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration 129, // 162: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 8, // 163: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType 130, // 164: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken 135, // 165: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh 131, // 166: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant 9, // 167: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 301, // 168: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration - 301, // 169: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration + 303, // 168: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration + 303, // 169: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration 133, // 170: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial 134, // 171: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 9, // 172: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 297, // 173: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp - 297, // 174: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp - 297, // 175: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp + 299, // 173: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp + 299, // 174: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp + 299, // 175: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp 16, // 176: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 297, // 177: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp - 302, // 178: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 177: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp + 304, // 178: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 136, // 179: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 302, // 180: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 180: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 9, // 181: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 284, // 182: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 297, // 183: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp + 286, // 182: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 299, // 183: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp 136, // 184: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 302, // 185: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 185: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 136, // 186: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 302, // 187: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 187: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 18, // 188: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome 10, // 189: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory 132, // 190: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 305, // 191: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 306, // 192: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 307, // 191: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 308, // 192: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary 137, // 193: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 285, // 194: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 287, // 194: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry 147, // 195: openshell.v1.ProviderProfile.files:type_name -> openshell.v1.ProviderProfileFile 146, // 196: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile 146, // 197: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 302, // 198: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 198: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 127, // 199: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem 128, // 200: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 146, // 201: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 302, // 202: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 202: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 127, // 203: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem 128, // 204: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 146, // 205: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 302, // 206: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 206: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 127, // 207: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem 128, // 208: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 18, // 209: openshell.v1.DeleteProviderResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 302, // 210: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 210: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 18, // 211: openshell.v1.DeleteProviderProfileResponse.outcome:type_name -> openshell.v1.DeletionOutcome 160, // 212: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 286, // 213: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 287, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - 288, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 289, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 288, // 213: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 289, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + 290, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 291, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry 4, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason - 290, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.files:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry + 292, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.files:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry 11, // 219: openshell.v1.ProviderEnvironmentValue.classification:type_name -> openshell.v1.ProviderEnvironmentValueClassification 161, // 220: openshell.v1.ProviderEnvironmentValue.static_credential_binding:type_name -> openshell.v1.StaticCredentialBinding - 297, // 221: openshell.v1.ProviderEnvironmentValue.expiration_time:type_name -> google.protobuf.Timestamp + 299, // 221: openshell.v1.ProviderEnvironmentValue.expiration_time:type_name -> google.protobuf.Timestamp 163, // 222: openshell.v1.ProviderEnvironmentSnapshot.values:type_name -> openshell.v1.ProviderEnvironmentValue - 291, // 223: openshell.v1.ProviderEnvironmentSnapshot.dynamic_credentials:type_name -> openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry + 293, // 223: openshell.v1.ProviderEnvironmentSnapshot.dynamic_credentials:type_name -> openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry 4, // 224: openshell.v1.ProviderEnvironmentSnapshot.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason - 292, // 225: openshell.v1.ProviderEnvironmentSnapshot.files:type_name -> openshell.v1.ProviderEnvironmentSnapshot.FilesEntry - 301, // 226: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration - 302, // 227: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 299, // 228: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 307, // 229: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 168, // 230: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 293, // 231: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 169, // 232: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 170, // 233: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 171, // 234: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 173, // 235: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 174, // 236: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 175, // 237: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 308, // 238: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 306, // 239: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 309, // 240: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 172, // 241: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget - 310, // 242: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 172, // 243: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget - 294, // 244: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 302, // 245: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 186, // 246: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 302, // 247: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 186, // 248: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 13, // 249: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 12, // 250: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState - 183, // 251: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 13, // 252: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 297, // 253: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp - 297, // 254: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp - 299, // 255: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 295, // 256: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 302, // 257: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 297, // 258: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp - 113, // 259: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 113, // 260: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 193, // 261: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 201, // 262: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 218, // 263: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 219, // 264: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 197, // 265: openshell.v1.SupervisorMessage.config_update_result:type_name -> openshell.v1.ConfigUpdateResult - 194, // 266: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 199, // 267: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 202, // 268: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 207, // 269: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 219, // 270: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 200, // 271: openshell.v1.GatewayMessage.session_redirect:type_name -> openshell.v1.SessionRedirect - 195, // 272: openshell.v1.GatewayMessage.config_update:type_name -> openshell.v1.ConfigUpdate - 195, // 273: openshell.v1.SessionAccepted.bootstrap:type_name -> openshell.v1.ConfigUpdate - 301, // 274: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration - 311, // 275: openshell.v1.ConfigUpdate.sandbox_config:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot - 164, // 276: openshell.v1.ConfigUpdate.provider_environment:type_name -> openshell.v1.ProviderEnvironmentSnapshot - 6, // 277: openshell.v1.ConfigUpdateResult.outcome:type_name -> openshell.v1.ConfigApplyOutcome - 196, // 278: openshell.v1.ConfigUpdateResult.failure:type_name -> openshell.v1.ConfigApplyFailure - 183, // 279: openshell.v1.ConfigUpdateResult.admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 198, // 280: openshell.v1.ConfigUpdateResult.policy_load:type_name -> openshell.v1.PolicyLoadResult - 208, // 281: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 209, // 282: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 210, // 283: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 207, // 284: openshell.v1.PeerRelayInit.relay_open:type_name -> openshell.v1.RelayOpen - 212, // 285: openshell.v1.PeerRelayFrame.init:type_name -> openshell.v1.PeerRelayInit - 214, // 286: openshell.v1.PeerNotifyConfigUpdateRequest.sandbox:type_name -> openshell.v1.PeerConfigSandboxTarget - 215, // 287: openshell.v1.PeerNotifyConfigUpdateRequest.provider:type_name -> openshell.v1.PeerConfigProviderTarget - 297, // 288: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp - 297, // 289: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp - 220, // 290: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 222, // 291: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 308, // 292: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 297, // 293: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp - 297, // 294: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp - 297, // 295: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp - 297, // 296: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp - 299, // 297: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 299, // 298: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 302, // 299: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 221, // 300: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 224, // 301: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 223, // 302: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 302, // 303: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 224, // 304: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 297, // 305: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp - 302, // 306: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 307: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 308: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 234, // 309: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 302, // 310: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 308, // 311: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 302, // 312: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 313: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 302, // 314: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 297, // 315: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp - 244, // 316: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 296, // 317: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 312, // 318: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 312, // 319: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 312, // 320: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 18, // 321: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 298, // 322: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 15, // 323: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 302, // 324: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 15, // 325: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 254, // 326: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 302, // 327: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 18, // 328: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 302, // 329: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 254, // 330: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 297, // 331: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp - 19, // 332: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult - 270, // 333: openshell.v1.EndpointObservation.failure:type_name -> openshell.v1.EndpointFailure - 262, // 334: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation - 264, // 335: openshell.v1.ReportEndpointStatusRequest.token_grant_status:type_name -> openshell.v1.TokenGrantStatus - 265, // 336: openshell.v1.TokenGrantStatus.last_failure:type_name -> openshell.v1.TokenGrantFailureObservation - 270, // 337: openshell.v1.TokenGrantFailureObservation.failure:type_name -> openshell.v1.EndpointFailure - 19, // 338: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult - 297, // 339: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp - 270, // 340: openshell.v1.EndpointStatus.last_failure:type_name -> openshell.v1.EndpointFailure - 297, // 341: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp - 297, // 342: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp - 297, // 343: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp - 297, // 344: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp - 297, // 345: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp - 297, // 346: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp - 297, // 347: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp - 297, // 348: openshell.v1.SandboxProvisioning.preparation_deadline:type_name -> google.protobuf.Timestamp - 297, // 349: openshell.v1.SandboxProvisioning.admission_start_time:type_name -> google.protobuf.Timestamp - 20, // 350: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode - 21, // 351: openshell.v1.EndpointFailure.domain:type_name -> openshell.v1.EndpointFailureDomain - 16, // 352: openshell.v1.EndpointFailure.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 118, // 353: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry.value:type_name -> openshell.v1.ProviderCredentialValue - 297, // 354: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 297, // 355: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 132, // 356: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 161, // 357: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 132, // 358: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 26, // 359: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 28, // 360: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 30, // 361: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 54, // 362: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 62, // 363: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 64, // 364: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 65, // 365: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 55, // 366: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 56, // 367: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 57, // 368: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 58, // 369: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 66, // 370: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 67, // 371: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 68, // 372: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 84, // 373: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest - 69, // 374: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 70, // 375: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 71, // 376: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 89, // 377: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 91, // 378: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 92, // 379: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 93, // 380: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 95, // 381: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 99, // 382: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 101, // 383: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 107, // 384: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 108, // 385: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 115, // 386: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 116, // 387: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 117, // 388: openshell.v1.OpenShell.GetProviderCredentials:input_type -> openshell.v1.GetProviderCredentialsRequest - 120, // 389: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 125, // 390: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 126, // 391: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 150, // 392: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 152, // 393: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 154, // 394: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 121, // 395: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 138, // 396: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 140, // 397: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 142, // 398: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 144, // 399: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 122, // 400: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 157, // 401: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 313, // 402: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 314, // 403: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 167, // 404: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 177, // 405: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 179, // 406: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 181, // 407: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 263, // 408: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 86, // 409: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest - 184, // 410: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest - 159, // 411: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 165, // 412: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 187, // 413: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 188, // 414: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 191, // 415: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 203, // 416: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 205, // 417: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 211, // 418: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 213, // 419: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame - 86, // 420: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest - 263, // 421: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 84, // 422: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest - 216, // 423: openshell.v1.OpenShell.PeerNotifyConfigUpdate:input_type -> openshell.v1.PeerNotifyConfigUpdateRequest - 111, // 424: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 226, // 425: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 228, // 426: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 230, // 427: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 232, // 428: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 235, // 429: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 237, // 430: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 239, // 431: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 241, // 432: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 243, // 433: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 22, // 434: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 24, // 435: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 246, // 436: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 248, // 437: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 250, // 438: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 252, // 439: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 255, // 440: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 257, // 441: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 259, // 442: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 27, // 443: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 29, // 444: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 31, // 445: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 72, // 446: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 63, // 447: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 72, // 448: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 73, // 449: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 59, // 450: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 59, // 451: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 60, // 452: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 61, // 453: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 74, // 454: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 75, // 455: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 76, // 456: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 85, // 457: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse - 88, // 458: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 72, // 459: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 72, // 460: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 90, // 461: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 98, // 462: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 98, // 463: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 94, // 464: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 96, // 465: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 100, // 466: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 105, // 467: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 107, // 468: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 105, // 469: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 123, // 470: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 123, // 471: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 119, // 472: openshell.v1.OpenShell.GetProviderCredentials:output_type -> openshell.v1.GetProviderCredentialsResponse - 124, // 473: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 149, // 474: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 148, // 475: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 151, // 476: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 153, // 477: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 155, // 478: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 123, // 479: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 139, // 480: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 141, // 481: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 143, // 482: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 145, // 483: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 156, // 484: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 158, // 485: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 315, // 486: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 316, // 487: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 176, // 488: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 178, // 489: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 180, // 490: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 182, // 491: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 266, // 492: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 87, // 493: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse - 185, // 494: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse - 162, // 495: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 166, // 496: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 190, // 497: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 189, // 498: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 192, // 499: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 204, // 500: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 206, // 501: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 211, // 502: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 213, // 503: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame - 87, // 504: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse - 266, // 505: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 85, // 506: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse - 217, // 507: openshell.v1.OpenShell.PeerNotifyConfigUpdate:output_type -> openshell.v1.PeerNotifyConfigUpdateResponse - 112, // 508: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 227, // 509: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 229, // 510: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 231, // 511: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 233, // 512: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 236, // 513: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 238, // 514: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 240, // 515: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 242, // 516: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 245, // 517: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 23, // 518: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 25, // 519: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 247, // 520: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 249, // 521: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 251, // 522: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 253, // 523: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 256, // 524: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 258, // 525: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 260, // 526: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 443, // [443:527] is the sub-list for method output_type - 359, // [359:443] is the sub-list for method input_type - 359, // [359:359] is the sub-list for extension type_name - 359, // [359:359] is the sub-list for extension extendee - 0, // [0:359] is the sub-list for field type_name + 294, // 225: openshell.v1.ProviderEnvironmentSnapshot.files:type_name -> openshell.v1.ProviderEnvironmentSnapshot.FilesEntry + 303, // 226: openshell.v1.GetSandboxProviderAccessTokenResponse.expires_after:type_name -> google.protobuf.Duration + 303, // 227: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration + 304, // 228: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 301, // 229: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 309, // 230: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 170, // 231: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 295, // 232: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 171, // 233: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 172, // 234: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 173, // 235: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 175, // 236: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 176, // 237: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 177, // 238: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 310, // 239: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 308, // 240: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 311, // 241: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 174, // 242: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget + 312, // 243: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 174, // 244: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget + 296, // 245: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 304, // 246: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 188, // 247: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 304, // 248: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 188, // 249: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 13, // 250: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 12, // 251: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState + 185, // 252: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 13, // 253: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 299, // 254: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp + 299, // 255: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp + 301, // 256: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 297, // 257: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 304, // 258: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 259: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp + 113, // 260: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 113, // 261: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 195, // 262: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 203, // 263: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 220, // 264: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 221, // 265: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 199, // 266: openshell.v1.SupervisorMessage.config_update_result:type_name -> openshell.v1.ConfigUpdateResult + 196, // 267: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 201, // 268: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 204, // 269: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 209, // 270: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 221, // 271: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 202, // 272: openshell.v1.GatewayMessage.session_redirect:type_name -> openshell.v1.SessionRedirect + 197, // 273: openshell.v1.GatewayMessage.config_update:type_name -> openshell.v1.ConfigUpdate + 197, // 274: openshell.v1.SessionAccepted.bootstrap:type_name -> openshell.v1.ConfigUpdate + 303, // 275: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration + 313, // 276: openshell.v1.ConfigUpdate.sandbox_config:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot + 164, // 277: openshell.v1.ConfigUpdate.provider_environment:type_name -> openshell.v1.ProviderEnvironmentSnapshot + 6, // 278: openshell.v1.ConfigUpdateResult.outcome:type_name -> openshell.v1.ConfigApplyOutcome + 198, // 279: openshell.v1.ConfigUpdateResult.failure:type_name -> openshell.v1.ConfigApplyFailure + 185, // 280: openshell.v1.ConfigUpdateResult.admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 200, // 281: openshell.v1.ConfigUpdateResult.policy_load:type_name -> openshell.v1.PolicyLoadResult + 210, // 282: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 211, // 283: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 212, // 284: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 209, // 285: openshell.v1.PeerRelayInit.relay_open:type_name -> openshell.v1.RelayOpen + 214, // 286: openshell.v1.PeerRelayFrame.init:type_name -> openshell.v1.PeerRelayInit + 216, // 287: openshell.v1.PeerNotifyConfigUpdateRequest.sandbox:type_name -> openshell.v1.PeerConfigSandboxTarget + 217, // 288: openshell.v1.PeerNotifyConfigUpdateRequest.provider:type_name -> openshell.v1.PeerConfigProviderTarget + 299, // 289: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp + 299, // 290: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp + 222, // 291: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 224, // 292: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 310, // 293: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 299, // 294: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp + 299, // 295: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp + 299, // 296: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp + 299, // 297: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp + 301, // 298: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 301, // 299: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 304, // 300: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 223, // 301: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 226, // 302: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 225, // 303: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 304, // 304: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 226, // 305: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 299, // 306: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp + 304, // 307: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 308: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 309: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 236, // 310: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 304, // 311: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 310, // 312: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 304, // 313: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 314: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 304, // 315: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 316: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp + 246, // 317: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 298, // 318: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 314, // 319: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 314, // 320: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 314, // 321: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 18, // 322: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 300, // 323: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 15, // 324: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 304, // 325: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 15, // 326: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 256, // 327: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 304, // 328: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 18, // 329: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 304, // 330: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 256, // 331: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 299, // 332: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp + 19, // 333: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult + 272, // 334: openshell.v1.EndpointObservation.failure:type_name -> openshell.v1.EndpointFailure + 264, // 335: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation + 266, // 336: openshell.v1.ReportEndpointStatusRequest.token_grant_status:type_name -> openshell.v1.TokenGrantStatus + 267, // 337: openshell.v1.TokenGrantStatus.last_failure:type_name -> openshell.v1.TokenGrantFailureObservation + 272, // 338: openshell.v1.TokenGrantFailureObservation.failure:type_name -> openshell.v1.EndpointFailure + 19, // 339: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult + 299, // 340: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp + 272, // 341: openshell.v1.EndpointStatus.last_failure:type_name -> openshell.v1.EndpointFailure + 299, // 342: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp + 299, // 343: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp + 299, // 344: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp + 299, // 345: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp + 299, // 346: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp + 299, // 347: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp + 299, // 348: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp + 299, // 349: openshell.v1.SandboxProvisioning.preparation_deadline:type_name -> google.protobuf.Timestamp + 299, // 350: openshell.v1.SandboxProvisioning.admission_start_time:type_name -> google.protobuf.Timestamp + 20, // 351: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode + 21, // 352: openshell.v1.EndpointFailure.domain:type_name -> openshell.v1.EndpointFailureDomain + 16, // 353: openshell.v1.EndpointFailure.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 118, // 354: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry.value:type_name -> openshell.v1.ProviderCredentialValue + 299, // 355: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 299, // 356: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 132, // 357: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 161, // 358: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 132, // 359: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 26, // 360: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 28, // 361: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 30, // 362: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 54, // 363: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 62, // 364: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 64, // 365: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 65, // 366: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 55, // 367: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 56, // 368: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 57, // 369: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 58, // 370: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 66, // 371: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 67, // 372: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 68, // 373: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 84, // 374: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 69, // 375: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 70, // 376: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 71, // 377: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 89, // 378: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 91, // 379: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 92, // 380: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 93, // 381: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 95, // 382: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 99, // 383: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 101, // 384: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 107, // 385: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 108, // 386: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 115, // 387: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 116, // 388: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 117, // 389: openshell.v1.OpenShell.GetProviderCredentials:input_type -> openshell.v1.GetProviderCredentialsRequest + 120, // 390: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 125, // 391: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 126, // 392: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 150, // 393: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 152, // 394: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 154, // 395: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 121, // 396: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 138, // 397: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 140, // 398: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 142, // 399: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 144, // 400: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 122, // 401: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 157, // 402: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 315, // 403: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 316, // 404: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 169, // 405: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 179, // 406: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 181, // 407: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 183, // 408: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 265, // 409: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 86, // 410: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 186, // 411: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest + 159, // 412: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 167, // 413: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 165, // 414: openshell.v1.OpenShell.GetSandboxProviderAccessToken:input_type -> openshell.v1.GetSandboxProviderAccessTokenRequest + 189, // 415: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 190, // 416: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 193, // 417: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 205, // 418: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 207, // 419: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 213, // 420: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 215, // 421: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame + 86, // 422: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 265, // 423: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 84, // 424: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 218, // 425: openshell.v1.OpenShell.PeerNotifyConfigUpdate:input_type -> openshell.v1.PeerNotifyConfigUpdateRequest + 111, // 426: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 228, // 427: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 230, // 428: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 232, // 429: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 234, // 430: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 237, // 431: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 239, // 432: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 241, // 433: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 243, // 434: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 245, // 435: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 22, // 436: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 24, // 437: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 248, // 438: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 250, // 439: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 252, // 440: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 254, // 441: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 257, // 442: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 259, // 443: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 261, // 444: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 27, // 445: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 29, // 446: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 31, // 447: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 72, // 448: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 63, // 449: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 72, // 450: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 73, // 451: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 59, // 452: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 59, // 453: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 60, // 454: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 61, // 455: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 74, // 456: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 75, // 457: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 76, // 458: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 85, // 459: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 88, // 460: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 72, // 461: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 72, // 462: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 90, // 463: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 98, // 464: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 98, // 465: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 94, // 466: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 96, // 467: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 100, // 468: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 105, // 469: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 107, // 470: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 105, // 471: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 123, // 472: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 123, // 473: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 119, // 474: openshell.v1.OpenShell.GetProviderCredentials:output_type -> openshell.v1.GetProviderCredentialsResponse + 124, // 475: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 149, // 476: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 148, // 477: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 151, // 478: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 153, // 479: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 155, // 480: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 123, // 481: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 139, // 482: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 141, // 483: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 143, // 484: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 145, // 485: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 156, // 486: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 158, // 487: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 317, // 488: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 318, // 489: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 178, // 490: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 180, // 491: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 182, // 492: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 184, // 493: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 268, // 494: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 87, // 495: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 187, // 496: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse + 162, // 497: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 168, // 498: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 166, // 499: openshell.v1.OpenShell.GetSandboxProviderAccessToken:output_type -> openshell.v1.GetSandboxProviderAccessTokenResponse + 192, // 500: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 191, // 501: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 194, // 502: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 206, // 503: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 208, // 504: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 213, // 505: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 215, // 506: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame + 87, // 507: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 268, // 508: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 85, // 509: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 219, // 510: openshell.v1.OpenShell.PeerNotifyConfigUpdate:output_type -> openshell.v1.PeerNotifyConfigUpdateResponse + 112, // 511: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 229, // 512: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 231, // 513: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 233, // 514: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 235, // 515: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 238, // 516: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 240, // 517: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 242, // 518: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 244, // 519: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 247, // 520: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 23, // 521: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 25, // 522: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 249, // 523: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 251, // 524: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 253, // 525: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 255, // 526: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 258, // 527: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 260, // 528: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 262, // 529: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 445, // [445:530] is the sub-list for method output_type + 360, // [360:445] is the sub-list for method input_type + 360, // [360:360] is the sub-list for extension type_name + 360, // [360:360] is the sub-list for extension extendee + 0, // [0:360] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -22094,7 +22215,7 @@ func file_openshell_proto_init() { (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[146].OneofWrappers = []any{ + file_openshell_proto_msgTypes[148].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -22102,15 +22223,15 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[150].OneofWrappers = []any{} - file_openshell_proto_msgTypes[169].OneofWrappers = []any{ + file_openshell_proto_msgTypes[152].OneofWrappers = []any{} + file_openshell_proto_msgTypes[171].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), (*SupervisorMessage_ConfigUpdateResult)(nil), } - file_openshell_proto_msgTypes[170].OneofWrappers = []any{ + file_openshell_proto_msgTypes[172].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), @@ -22119,32 +22240,32 @@ func file_openshell_proto_init() { (*GatewayMessage_SessionRedirect)(nil), (*GatewayMessage_ConfigUpdate)(nil), } - file_openshell_proto_msgTypes[185].OneofWrappers = []any{ + file_openshell_proto_msgTypes[187].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[189].OneofWrappers = []any{ + file_openshell_proto_msgTypes[191].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[191].OneofWrappers = []any{ + file_openshell_proto_msgTypes[193].OneofWrappers = []any{ (*PeerRelayFrame_Init)(nil), (*PeerRelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[194].OneofWrappers = []any{ + file_openshell_proto_msgTypes[196].OneofWrappers = []any{ (*PeerNotifyConfigUpdateRequest_Sandbox)(nil), (*PeerNotifyConfigUpdateRequest_Workspace)(nil), (*PeerNotifyConfigUpdateRequest_AllConnected)(nil), (*PeerNotifyConfigUpdateRequest_Provider)(nil), } - file_openshell_proto_msgTypes[248].OneofWrappers = []any{} + file_openshell_proto_msgTypes[250].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 22, - NumMessages: 275, + NumMessages: 277, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 57f17c466b..16cc664780 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -77,6 +77,7 @@ const ( OpenShell_ReportSandboxConfiguration_FullMethodName = "/openshell.v1.OpenShell/ReportSandboxConfiguration" OpenShell_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" OpenShell_ExchangeProviderSubjectToken_FullMethodName = "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" + OpenShell_GetSandboxProviderAccessToken_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderAccessToken" OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" @@ -252,6 +253,9 @@ type OpenShellClient interface { // Exchange a stored provider subject token for an intermediate token scoped // to the calling supervisor's SPIFFE identity. ExchangeProviderSubjectToken(ctx context.Context, in *ExchangeProviderSubjectTokenRequest, opts ...grpc.CallOption) (*ExchangeProviderSubjectTokenResponse, error) + // Obtain the final provider token for the authenticated sandbox session. + // SPIFFE assertions and intermediate credentials remain in the gateway. + GetSandboxProviderAccessToken(ctx context.Context, in *GetSandboxProviderAccessTokenRequest, opts ...grpc.CallOption) (*GetSandboxProviderAccessTokenResponse, error) // Fetch recent sandbox logs (one-shot). GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) // Push sandbox supervisor logs to the server (client-streaming). @@ -914,6 +918,16 @@ func (c *openShellClient) ExchangeProviderSubjectToken(ctx context.Context, in * return out, nil } +func (c *openShellClient) GetSandboxProviderAccessToken(ctx context.Context, in *GetSandboxProviderAccessTokenRequest, opts ...grpc.CallOption) (*GetSandboxProviderAccessTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxProviderAccessTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxProviderAccessToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) GetSandboxLogs(ctx context.Context, in *GetSandboxLogsRequest, opts ...grpc.CallOption) (*GetSandboxLogsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetSandboxLogsResponse) @@ -1378,6 +1392,9 @@ type OpenShellServer interface { // Exchange a stored provider subject token for an intermediate token scoped // to the calling supervisor's SPIFFE identity. ExchangeProviderSubjectToken(context.Context, *ExchangeProviderSubjectTokenRequest) (*ExchangeProviderSubjectTokenResponse, error) + // Obtain the final provider token for the authenticated sandbox session. + // SPIFFE assertions and intermediate credentials remain in the gateway. + GetSandboxProviderAccessToken(context.Context, *GetSandboxProviderAccessTokenRequest) (*GetSandboxProviderAccessTokenResponse, error) // Fetch recent sandbox logs (one-shot). GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) // Push sandbox supervisor logs to the server (client-streaming). @@ -1647,6 +1664,9 @@ func (UnimplementedOpenShellServer) GetSandboxProviderEnvironment(context.Contex func (UnimplementedOpenShellServer) ExchangeProviderSubjectToken(context.Context, *ExchangeProviderSubjectTokenRequest) (*ExchangeProviderSubjectTokenResponse, error) { return nil, status.Error(codes.Unimplemented, "method ExchangeProviderSubjectToken not implemented") } +func (UnimplementedOpenShellServer) GetSandboxProviderAccessToken(context.Context, *GetSandboxProviderAccessTokenRequest) (*GetSandboxProviderAccessTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxProviderAccessToken not implemented") +} func (UnimplementedOpenShellServer) GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetSandboxLogs not implemented") } @@ -2701,6 +2721,24 @@ func _OpenShell_ExchangeProviderSubjectToken_Handler(srv interface{}, ctx contex return interceptor(ctx, in, info, handler) } +func _OpenShell_GetSandboxProviderAccessToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxProviderAccessTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxProviderAccessToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxProviderAccessToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxProviderAccessToken(ctx, req.(*GetSandboxProviderAccessTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_GetSandboxLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetSandboxLogsRequest) if err := dec(in); err != nil { @@ -3401,6 +3439,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "ExchangeProviderSubjectToken", Handler: _OpenShell_ExchangeProviderSubjectToken_Handler, }, + { + MethodName: "GetSandboxProviderAccessToken", + Handler: _OpenShell_GetSandboxProviderAccessToken_Handler, + }, { MethodName: "GetSandboxLogs", Handler: _OpenShell_GetSandboxLogs_Handler, diff --git a/tasks/scripts/test-spire-delegation.sh b/tasks/scripts/test-spire-delegation.sh new file mode 100755 index 0000000000..3212007f7f --- /dev/null +++ b/tasks/scripts/test-spire-delegation.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Run the gateway's SPIRE delegated-identity test against a real +# SPIRE server and agent. +# +# Downloads a pinned, checksum-verified SPIRE release, starts a server and an +# agent with a private admin socket, authorizes this user's processes as the +# delegate, registers the sandbox entries the test expects, and runs the +# ignored `spire_tests` test in openshell-server. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SPIRE_VERSION="1.15.4" +case "$(uname -m)" in + x86_64 | amd64) + SPIRE_ARCH=amd64 + SPIRE_SHA256=d8e3ee9a9d48636249484d91af37566dfc0a9d7e71edcbe7acd49cf18bc3c87c + ;; + aarch64 | arm64) + SPIRE_ARCH=arm64 + SPIRE_SHA256=81d9b69d662c7813f905805cafd9f2e6d2255639b65c9e1b7297deb2b1df3eb0 + ;; + *) + echo "unsupported architecture: $(uname -m)" >&2 + exit 2 + ;; +esac +if [ "$(uname -s)" != "Linux" ]; then + echo "test:spire-delegation requires Linux" >&2 + exit 2 +fi + +CACHE="${OPENSHELL_SPIRE_CACHE:-${ROOT}/target/spire}" +ARCHIVE="${CACHE}/spire-${SPIRE_VERSION}-linux-${SPIRE_ARCH}-musl.tar.gz" +BIN="${CACHE}/spire-${SPIRE_VERSION}/bin" +mkdir -p "${CACHE}" +if [ ! -x "${BIN}/spire-server" ] || [ ! -x "${BIN}/spire-agent" ]; then + curl -fsSL -o "${ARCHIVE}" \ + "https://github.com/spiffe/spire/releases/download/v${SPIRE_VERSION}/spire-${SPIRE_VERSION}-linux-${SPIRE_ARCH}-musl.tar.gz" + echo "${SPIRE_SHA256} ${ARCHIVE}" | sha256sum -c - + tar -xzf "${ARCHIVE}" -C "${CACHE}" \ + "spire-${SPIRE_VERSION}/bin/spire-server" "spire-${SPIRE_VERSION}/bin/spire-agent" +fi + +# Unix socket paths must stay short. +WORK="$(mktemp -d /tmp/osspire.XXXXXX)" +PIDS=() +cleanup() { + for pid in "${PIDS[@]}"; do + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + done + rm -rf "${WORK}" +} +trap cleanup EXIT + +TRUST_DOMAIN="openshell.test" +GATEWAY_ID="gw-test" +ID_PREFIX="spiffe://${TRUST_DOMAIN}/openshell" +DELEGATE_ID="spiffe://${TRUST_DOMAIN}/openshell/shared-gateway" +NODE_ID="spiffe://${TRUST_DOMAIN}/node/test" +SERVER_SOCKET="${WORK}/server/api.sock" +ADMIN_SOCKET="${WORK}/agent/private/admin.sock" +SERVER_PORT="$(uv run --no-project python -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" +mkdir -p "${WORK}/server" "${WORK}/agent/public" "${WORK}/agent/private" + +cat >"${WORK}/server.conf" <"${WORK}/agent.conf" </dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + echo "${binary} did not become healthy" >&2 + return 1 +} + +server() { + "${BIN}/spire-server" "$@" -socketPath "${SERVER_SOCKET}" +} + +"${BIN}/spire-server" run -config "${WORK}/server.conf" >"${WORK}/server.log" 2>&1 & +PIDS+=($!) +wait_healthy spire-server "${SERVER_SOCKET}" +server bundle show >"${WORK}/bundle.pem" +JOIN_TOKEN="$(server token generate -spiffeID "${NODE_ID}" | awk '/^Token:/ {print $2}')" +"${BIN}/spire-agent" run -config "${WORK}/agent.conf" -joinToken "${JOIN_TOKEN}" >"${WORK}/agent.log" 2>&1 & +PIDS+=($!) +wait_healthy spire-agent "${WORK}/agent/public/api.sock" + +entry() { + server entry create -parentID "${NODE_ID}" -spiffeID "$1" -selector "$2" >/dev/null +} +sandbox_id() { + cat /proc/sys/kernel/random/uuid +} +sandbox_entry() { + entry "${ID_PREFIX}/gateway/${GATEWAY_ID}/sandbox/$2" "openshell:sandbox:${GATEWAY_ID}:$1" +} + +# The delegate: this user's processes, attested over the admin socket. +entry "${DELEGATE_ID}" "unix:uid:$(id -u)" +ALPHA="$(sandbox_id)" +BETA="$(sandbox_id)" +UNREGISTERED="$(sandbox_id)" +MISREGISTERED="$(sandbox_id)" +AMBIGUOUS="$(sandbox_id)" +sandbox_entry "${ALPHA}" "${ALPHA}" +sandbox_entry "${BETA}" "${BETA}" +# Operator mistakes the gateway must refuse rather than resolve. +sandbox_entry "${MISREGISTERED}" "${ALPHA}" +sandbox_entry "${AMBIGUOUS}" "${AMBIGUOUS}" +sandbox_entry "${AMBIGUOUS}" "${AMBIGUOUS}-other" + +status=0 +OPENSHELL_TEST_SPIRE_ADMIN_SOCKET="${ADMIN_SOCKET}" \ + OPENSHELL_TEST_SPIRE_ID_PREFIX="${ID_PREFIX}" \ + OPENSHELL_TEST_SPIRE_GATEWAY_ID="${GATEWAY_ID}" \ + OPENSHELL_TEST_SPIRE_SANDBOX_ALPHA="${ALPHA}" \ + OPENSHELL_TEST_SPIRE_SANDBOX_BETA="${BETA}" \ + OPENSHELL_TEST_SPIRE_SANDBOX_UNREGISTERED="${UNREGISTERED}" \ + OPENSHELL_TEST_SPIRE_SANDBOX_MISREGISTERED="${MISREGISTERED}" \ + OPENSHELL_TEST_SPIRE_SANDBOX_AMBIGUOUS="${AMBIGUOUS}" \ + cargo test -p openshell-server --lib spiffe_delegation::tests \ + -- --ignored --nocapture || status=$? +if [ "${status}" -ne 0 ]; then + echo "--- spire-server log" >&2 + tail -n 50 "${WORK}/server.log" >&2 || true + echo "--- spire-agent log" >&2 + tail -n 50 "${WORK}/agent.log" >&2 || true +fi +exit "${status}" diff --git a/tasks/test.toml b/tasks/test.toml index a5a2a03928..aeb0a94891 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -25,6 +25,11 @@ depends = [ "test:maintainer-approval", ] +["test:spire-delegation"] +description = "Test gateway-owned SPIRE delegated sandbox identities against a real SPIRE server and agent" +run = "tasks/scripts/test-spire-delegation.sh" +run_windows = "echo Skipping test:spire-delegation: SPIRE delegation requires Linux." + ["test:docs-website"] description = "Test the docs-website sync script" # --no-project skips installing the OpenShell package; --with supplies the test @@ -273,6 +278,11 @@ env = { OPENSHELL_E2E_KUBE_TEST = "kubernetes_shared_supervisor", OPENSHELL_E2E_ # Prepend the supervisor overlay to any caller overlays, such as a workspace mode. run = 'OPENSHELL_E2E_KUBE_EXTRA_VALUES="deploy/helm/openshell/ci/values-supervisor.yaml${OPENSHELL_E2E_KUBE_EXTRA_VALUES:+:$OPENSHELL_E2E_KUBE_EXTRA_VALUES}" e2e/rust/e2e-kubernetes.sh' +["e2e:kubernetes:shared-supervisor-spiffe"] +description = "Run Kubernetes e2e with SPIRE delegating sandbox identities to the shared supervisor (installs pinned SPIRE charts)" +env = { OPENSHELL_E2E_KUBE_SPIRE_DELEGATION = "1", OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-supervisor-spiffe.yaml", OPENSHELL_E2E_KUBE_TEST = "kubernetes_shared_supervisor_spiffe", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-host-gateway,e2e-kubernetes,e2e-kubernetes-shared-supervisor-spiffe" } +run = "e2e/rust/e2e-kubernetes.sh" + ["e2e:kubernetes:workspace-operator"] description = "Run Kubernetes e2e with operator workspace mode (pre-provisioned per-workspace namespaces)" env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-workspace-operator.yaml", OPENSHELL_E2E_KUBE_TEST = "workspace_namespace_operator", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-workspace-operator" } From 7410286041c5b50e2024a2ed0e6c88423640b6fd Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 15:49:40 -0700 Subject: [PATCH 2/8] refactor(gateway): rename SPIFFE delegation configuration Signed-off-by: Drew Newberry --- .agents/skills/helm-dev-environment/SKILL.md | 2 +- crates/openshell-core/src/config.rs | 12 +++++------ crates/openshell-driver-kubernetes/README.md | 2 +- crates/openshell-server/README.md | 4 ++-- crates/openshell-server/src/cli.rs | 9 +++------ crates/openshell-server/src/config_file.rs | 3 +-- .../openshell-server/src/spiffe_delegation.rs | 20 +++++++++---------- .../ci/values-spire-delegation-stack.yaml | 2 +- .../ci/values-supervisor-spiffe.yaml | 2 +- .../openshell/templates/_gateway-workload.tpl | 2 +- deploy/helm/openshell/templates/_helpers.tpl | 10 +++++----- .../tests/supervisor_wiring_test.yaml | 2 +- skills/debug-openshell-cluster/SKILL.md | 5 +++++ 13 files changed, 37 insertions(+), 38 deletions(-) diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index e5c1e6b1b3..4f35570d7a 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -98,7 +98,7 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \ `mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts (`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the -`openshell.gateway.experimental_spiffe_delegation` overlay +`openshell.gateway.spiffe_delegation` overlay (`ci/values-supervisor-spiffe.yaml`), then registers sandbox identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation` checks delegated issuance and identity selection against a local SPIRE server diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index cc66bc2a0a..195c72cea4 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -205,17 +205,17 @@ pub fn normalize_compute_driver_name(value: &str) -> Result { Ok(value.to_ascii_lowercase()) } -/// Experimental gateway-owned SPIRE delegation for sandbox provider grants. +/// Gateway-owned SPIRE delegation for sandbox provider grants. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] -pub struct ExperimentalSpiffeDelegationConfig { +pub struct SpiffeDelegationConfig { pub admin_socket_path: String, pub spiffe_id_prefix: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub admin_socket_group: Option, } -impl ExperimentalSpiffeDelegationConfig { +impl SpiffeDelegationConfig { /// Reject ambiguous host paths and identities before opening the SPIRE socket. pub fn validate(&self, gateway_id: &str) -> Result<(), String> { let path = std::path::Path::new(&self.admin_socket_path); @@ -230,7 +230,7 @@ impl ExperimentalSpiffeDelegationConfig { .any(|part| part.is_empty() || part == "." || part == "..") || self.admin_socket_group == Some(0) { - return Err("experimental_spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into()); + return Err("spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into()); } crate::spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)? .sandbox_spiffe_id(gateway_id, "sandbox")?; @@ -303,7 +303,7 @@ pub struct Config { /// flow (issue #1354). pub gateway_jwt: Option, /// Gateway-only access to SPIRE; shared supervisors receive access tokens. - pub experimental_spiffe_delegation: Option, + pub spiffe_delegation: Option, /// Database URL for persistence. pub database_url: String, @@ -975,7 +975,7 @@ impl Config { provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User], mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, - experimental_spiffe_delegation: None, + spiffe_delegation: None, database_url: String::new(), database_max_connections: None, database_lock_max_connections: None, diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 55b1476fad..b08911ac81 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -255,7 +255,7 @@ fails closed. and the routing preface only selects which generation-pinned TLS identity the supervisor demands. - **Workload identity.** The gateway owns SPIRE delegation and provider-token - exchange when `openshell.gateway.experimental_spiffe_delegation` is configured. + exchange when `openshell.gateway.spiffe_delegation` is configured. Shared supervisors receive final access tokens through their authenticated sessions. No SPIFFE socket is mounted in a shared supervisor or workload Pod. The existing Workload API option supplies the gateway's component identity diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 897db62125..ad9fb64340 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -85,9 +85,9 @@ runs under `mise run test:rust:postgres` (see `TESTING.md`) and still accepts It exercises independent sessions without the local mutex. Do not point this fixture at an existing deployment database. -## Experimental sandbox identity broker +## Sandbox identity broker -`openshell.gateway.experimental_spiffe_delegation` enables gateway-owned SPIRE +`openshell.gateway.spiffe_delegation` enables gateway-owned SPIRE acquisition for the Kubernetes shared supervisor. The private admin socket is mounted only in the gateway. `GetSandboxProviderAccessToken` accepts an endpoint-bound configured credential key; sandbox identity comes from the diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2589ede441..c652f85957 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -649,12 +649,9 @@ fn prepare_server_config_with_drivers( // the generated TLS bundle so upgrades pick up sandbox auth without a // user-authored config file. config.gateway_jwt = gateway_jwt; - config.experimental_spiffe_delegation = file.as_ref().and_then(|file| { - file.openshell - .gateway - .experimental_spiffe_delegation - .clone() - }); + config.spiffe_delegation = file + .as_ref() + .and_then(|file| file.openshell.gateway.spiffe_delegation.clone()); // Reject invalid delegation before startup; gateway state parses it later. crate::spiffe_delegation::SpiffeDelegation::validate_config(&config) diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 3a260839da..bdcdbf8f32 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -189,8 +189,7 @@ pub struct GatewayFileSection { #[serde(default)] pub gateway_jwt: Option, #[serde(default)] - pub experimental_spiffe_delegation: - Option, + pub spiffe_delegation: Option, #[serde(default)] pub otlp: Option, #[serde(default)] diff --git a/crates/openshell-server/src/spiffe_delegation.rs b/crates/openshell-server/src/spiffe_delegation.rs index f7766d592d..eb3b52fd34 100644 --- a/crates/openshell-server/src/spiffe_delegation.rs +++ b/crates/openshell-server/src/spiffe_delegation.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //! SPIRE delegated issuance owned by the gateway, never by a supervisor or sandbox. -use openshell_core::config::{Config, ExperimentalSpiffeDelegationConfig}; +use openshell_core::config::{Config, SpiffeDelegationConfig}; use openshell_core::spiffe::SandboxSpiffeIdPrefix; use openshell_core::token_grant_failure::{ TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, @@ -31,7 +31,7 @@ impl SpiffeDelegation { /// # Errors /// /// Rejects delegation without gateway session JWTs and a positive session - /// TTL, and settings [`ExperimentalSpiffeDelegationConfig::validate`] + /// TTL, and settings [`SpiffeDelegationConfig::validate`] /// rejects. pub fn validate_config(config: &Config) -> Result<(), String> { enabled(config).map(drop) @@ -49,11 +49,11 @@ impl SpiffeDelegation { } /// Parse `config`, already validated for the gateway `gateway_id`. - fn new(config: &ExperimentalSpiffeDelegationConfig, gateway_id: &str) -> Result { + fn new(config: &SpiffeDelegationConfig, gateway_id: &str) -> Result { let prefix = SandboxSpiffeIdPrefix::parse(&config.spiffe_id_prefix)?; let trust_domain = openshell_core::spiffe::trust_domain(prefix.as_str()) .and_then(|name| spiffe::TrustDomain::new(name).ok()) - .ok_or("experimental_spiffe_delegation prefix has no valid trust domain")?; + .ok_or("spiffe_delegation prefix has no valid trust domain")?; Ok(Self { admin_socket: PathBuf::from(&config.admin_socket_path), prefix, @@ -113,19 +113,17 @@ impl SpiffeDelegation { } /// Validated delegation settings and gateway ID, when delegation is enabled. -fn enabled(config: &Config) -> Result, String> { - let Some(delegation) = &config.experimental_spiffe_delegation else { +fn enabled(config: &Config) -> Result, String> { + let Some(delegation) = &config.spiffe_delegation else { return Ok(None); }; let jwt = config .gateway_jwt .as_ref() - .ok_or("experimental_spiffe_delegation requires gateway session JWTs")?; + .ok_or("spiffe_delegation requires gateway session JWTs")?; delegation.validate(&jwt.gateway_id)?; if jwt.ttl_secs.is_none() { - return Err( - "experimental_spiffe_delegation requires a positive gateway_jwt.ttl_secs".into(), - ); + return Err("spiffe_delegation requires a positive gateway_jwt.ttl_secs".into()); } Ok(Some((delegation, &jwt.gateway_id))) } @@ -342,7 +340,7 @@ mod tests { #[ignore = "requires the real SPIRE fixture; run mise run test:spire-delegation"] async fn gateway_obtains_only_the_expected_sandbox_identity() { let env = |key| std::env::var(key).expect("SPIRE fixture variable"); - let config = ExperimentalSpiffeDelegationConfig { + let config = SpiffeDelegationConfig { admin_socket_path: env("OPENSHELL_TEST_SPIRE_ADMIN_SOCKET"), spiffe_id_prefix: env("OPENSHELL_TEST_SPIRE_ID_PREFIX"), admin_socket_group: None, diff --git a/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml b/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml index 00bdd6e505..ae265727ed 100644 --- a/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml +++ b/deploy/helm/openshell/ci/values-spire-delegation-stack.yaml @@ -18,7 +18,7 @@ spire-server: spire-agent: # The agent keeps root for workload attestation but creates its admin # socket (mode 0770) with this group, which only the gateway - # joins (experimental_spiffe_delegation.admin_socket_group). + # joins (spiffe_delegation.admin_socket_group). podSecurityContext: runAsUser: 0 runAsGroup: 10035 diff --git a/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml index c6162d236e..6d42f2becd 100644 --- a/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml +++ b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml @@ -9,7 +9,7 @@ gatewayConfig: openshell.drivers.kubernetes: supervisor_topology: gateway provider_spiffe_workload_api_socket_path: /spiffe-workload-api/spire-agent.sock - openshell.gateway.experimental_spiffe_delegation: + openshell.gateway.spiffe_delegation: admin_socket_path: /run/spire/agent/sockets/csi.spiffe.io/admin/admin.sock spiffe_id_prefix: spiffe://openshell.local/openshell admin_socket_group: 10035 diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 52a05ada1c..394ba968d8 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -9,7 +9,7 @@ Gateway pod template shared by the StatefulSet and Deployment workload shapes. {{- $oidcRuntimeConfig := get $gatewayConfig "openshell.gateway.oidc" | default dict -}} {{- if not (get $oidcRuntimeConfig "issuer") -}}{{- $oidcRuntimeConfig = .Values.server.oidc | default dict -}}{{- end -}} {{- $kubernetesRuntimeConfig := include "openshell.effectiveKubernetesConfig" . | fromYaml -}} -{{- $delegation := get $gatewayConfig "openshell.gateway.experimental_spiffe_delegation" | default dict -}} +{{- $delegation := get $gatewayConfig "openshell.gateway.spiffe_delegation" | default dict -}} {{- $spiffeSocketPath := get $kubernetesRuntimeConfig "provider_spiffe_workload_api_socket_path" -}} {{- $hasExternalCredentialDriver := or (eq (include "openshell.credentialDriverEnabled" (list . "kubernetes-secrets")) "true") (eq (include "openshell.credentialDriverEnabled" (list . "vault")) "true") -}} {{- $vaultCredentialDriverEnabled := eq (include "openshell.credentialDriverEnabled" (list . "vault")) "true" -}} diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index d5ce02e91d..0b78a79e21 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -388,13 +388,13 @@ never mounted in a supervisor or sandbox Pod. {{- $prefix := get $delegation "spiffe_id_prefix" | default "" -}} {{- $canonical := "^(/[A-Za-z0-9_.-]+){2,}$" -}} {{- if or (not (regexMatch $canonical $admin)) (regexMatch "/\\.\\.?(/|$)" $admin) -}} -{{- fail "experimental_spiffe_delegation.admin_socket_path must be a canonical absolute UNIX socket path below a dedicated directory" -}} +{{- fail "spiffe_delegation.admin_socket_path must be a canonical absolute UNIX socket path below a dedicated directory" -}} {{- end -}} {{- if and (hasKey $delegation "admin_socket_group") (not (regexMatch "^[1-9][0-9]*$" (toString (get $delegation "admin_socket_group")))) -}} -{{- fail "experimental_spiffe_delegation.admin_socket_group must be a positive group ID" -}} +{{- fail "spiffe_delegation.admin_socket_group must be a positive group ID" -}} {{- end -}} {{- if not (regexMatch "^spiffe://[a-z0-9._-]+(/[A-Za-z0-9._-]+)*$" $prefix) -}} -{{- fail "experimental_spiffe_delegation.spiffe_id_prefix must be spiffe://[/...]" -}} +{{- fail "spiffe_delegation.spiffe_id_prefix must be spiffe://[/...]" -}} {{- end -}} {{- end }} @@ -671,10 +671,10 @@ Validate chart values that Helm would otherwise accept silently. {{- fail "supervisor_topology=gateway in operator workspace mode requires operator_namespace_label of the form key=value" -}} {{- end -}} {{- end -}} -{{- $delegation := get (.Values.gatewayConfig | default dict) "openshell.gateway.experimental_spiffe_delegation" | default dict -}} +{{- $delegation := get (.Values.gatewayConfig | default dict) "openshell.gateway.spiffe_delegation" | default dict -}} {{- if $delegation -}} {{- if ne (get $kubernetes "supervisor_topology" | default "") "gateway" -}} -{{- fail "experimental_spiffe_delegation requires supervisor_topology=gateway" -}} +{{- fail "spiffe_delegation requires supervisor_topology=gateway" -}} {{- end -}} {{- include "openshell.validateSpiffeDelegation" (dict "delegation" $delegation) -}} {{- end -}} diff --git a/deploy/helm/openshell/tests/supervisor_wiring_test.yaml b/deploy/helm/openshell/tests/supervisor_wiring_test.yaml index aa60b93c9a..61eb5d0023 100644 --- a/deploy/helm/openshell/tests/supervisor_wiring_test.yaml +++ b/deploy/helm/openshell/tests/supervisor_wiring_test.yaml @@ -129,7 +129,7 @@ tests: gatewayConfig: openshell.drivers.kubernetes: supervisor_topology: gateway - openshell.gateway.experimental_spiffe_delegation: + openshell.gateway.spiffe_delegation: admin_socket_path: /run/spire/admin/admin.sock spiffe_id_prefix: spiffe://example.org/openshell admin_socket_group: 10035 diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 40351c998e..fd3746a5bd 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -838,6 +838,11 @@ kubectl -n openshell get statefulset openshell -o jsonpath='{.spec.template.spec # Should show items filter for ca.crt from openshell-server-tls ``` +With `gatewayConfig.openshell.gateway.spiffe_delegation`, the gateway brokers +provider tokens for shared-supervisor sandboxes using the SPIRE admin socket. +Check that config table and the gateway's `spire-agent-admin` mount; the shared +supervisor receives final access tokens and does not mount the SPIRE socket. + If `gatewayConfig.openshell.drivers.kubernetes.provider_spiffe_workload_api_socket_path` is set, the gateway should still render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret. From 22456ba92448706cab557bd3f1e40127d505f923 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 16:42:59 -0700 Subject: [PATCH 3/8] fix(provider): reuse broker channels and attribute grant failures Signed-off-by: Drew Newberry --- crates/openshell-core/src/grpc_client.rs | 103 ++++++++++++------ crates/openshell-server/src/grpc/provider.rs | 49 +++++++++ .../src/grpc/provider/access_token.rs | 8 +- crates/openshell-supervisor-network/README.md | 5 +- .../src/token_grant.rs | 24 ++-- 5 files changed, 138 insertions(+), 51 deletions(-) diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 132d3568c8..d9c28ba21e 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1578,44 +1578,75 @@ pub async fn exchange_provider_subject_token( )) } -/// Obtain only the final access token; the gateway selects and verifies the -/// sandbox identity and resolves all grant parameters from its configuration. -#[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)] -pub async fn get_sandbox_provider_access_token( - gateway: &GatewayEndpoint, - credential_key: &str, - request_id: Option<&str>, -) -> std::result::Result { - let endpoint = token_grant_gateway_endpoint(gateway.url())?; - let mut client = connect(&gateway.with_url(endpoint.as_str())) - .await - .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?; - let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest { - credential_key: credential_key.to_string(), - }); - if let Some(value) = request_id.and_then(|value| value.parse().ok()) { - request.metadata_mut().insert("x-request-id", value); +/// Final provider access tokens for one sandbox instance over one reused +/// gateway channel. +/// +/// The channel connects on the first request; a failed connection is retried +/// by the next one. Concurrent requests share the channel without holding a +/// lock across the call, and tonic reconnects it after transport loss. Each +/// request reads the instance's current bearer, so renewal and revocation +/// apply without reconnecting, and the gateway authorizes every request. +pub struct SandboxProviderTokenClient { + gateway: GatewayEndpoint, + client: tokio::sync::OnceCell>, +} + +impl SandboxProviderTokenClient { + #[must_use] + pub fn new(gateway: GatewayEndpoint) -> Self { + Self { + gateway, + client: tokio::sync::OnceCell::new(), + } } - let response = tokio::time::timeout( - Duration::from_mins(1), - client.get_sandbox_provider_access_token(request), - ) - .await - .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))? - .map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))? - .into_inner(); - // connect() and the response boundary both reject locally revoked sandbox - // instance credentials. - if gateway.credentials().is_revoked() { - return Err(gateway_grant_failure( - TokenGrantFailureKind::WorkloadIdentityRejected, - )); + + /// Obtain only the final access token; the gateway selects and verifies + /// the sandbox identity and resolves all grant parameters from its + /// configuration. + #[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)] + pub async fn get_access_token( + &self, + credential_key: &str, + request_id: Option<&str>, + ) -> std::result::Result { + let endpoint = token_grant_gateway_endpoint(self.gateway.url())?; + // Like connect(), refuse locally revoked sandbox instance credentials + // before the call; the response boundary rejects them again. + let unavailable = || gateway_grant_failure(TokenGrantFailureKind::Unavailable); + if self.gateway.credentials().is_revoked() { + return Err(unavailable()); + } + let mut client = self + .client + .get_or_try_init(|| async { connect(&self.gateway.with_url(endpoint.as_str())).await }) + .await + .map_err(|_| unavailable())? + .clone(); + let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest { + credential_key: credential_key.to_string(), + }); + if let Some(value) = request_id.and_then(|value| value.parse().ok()) { + request.metadata_mut().insert("x-request-id", value); + } + let response = tokio::time::timeout( + Duration::from_mins(1), + client.get_sandbox_provider_access_token(request), + ) + .await + .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))? + .map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))? + .into_inner(); + if self.gateway.credentials().is_revoked() { + return Err(gateway_grant_failure( + TokenGrantFailureKind::WorkloadIdentityRejected, + )); + } + provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse { + access_token: response.access_token, + expires_after: response.expires_after, + token_type: response.token_type, + }) } - provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse { - access_token: response.access_token, - expires_after: response.expires_after, - token_type: response.token_type, - }) } /// Attach the exchange's request ID as `x-request-id` gRPC metadata. diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 9d08d758b1..8d192ff06b 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5714,6 +5714,55 @@ mod tests { assert!(!logs.contains(SECRET), "{logs}"); } + #[tokio::test] + async fn broker_failure_log_names_authenticated_sandbox() { + let state = test_server_state_without_provider_profiles().await; + let mut request = Request::new( + openshell_core::proto::GetSandboxProviderAccessTokenRequest { + credential_key: SECRET.to_string(), + }, + ); + request + .extensions_mut() + .insert(Principal::Sandbox(SandboxPrincipal { + sandbox_id: "broker-sandbox".to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: SECRET.to_string(), + }, + trust_domain: None, + })); + request + .metadata_mut() + .insert("x-request-id", REQUEST_ID.parse().unwrap()); + + let output = GrantTraceBuffer::default(); + let writer = output.clone(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .without_time() + .with_writer(move || writer.clone()) + .finish(); + let capture = crate::otel_tracing::test_exporter::install_scoped(subscriber); + let error = crate::grpc::provider::access_token::handle(&state, request) + .await + .unwrap_err(); + drop(capture); + + let failure = assert_generic(&error, Code::Unauthenticated); + let logs = String::from_utf8(output.0.lock().unwrap().clone()).unwrap(); + assert!( + logs.contains(&format!( + "dynamic token grant failed request_id={REQUEST_ID} code={} stage={}", + failure.code(), + failure.stage().code(), + )), + "{logs}" + ); + assert!(logs.contains("sandbox_id=broker-sandbox"), "{logs}"); + assert!(!logs.contains(SECRET), "{logs}"); + } + #[tokio::test] async fn missing_expired_or_unresolvable_subject_credential_is_generic() { let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); diff --git a/crates/openshell-server/src/grpc/provider/access_token.rs b/crates/openshell-server/src/grpc/provider/access_token.rs index 15cc8829c1..87ee87fd63 100644 --- a/crates/openshell-server/src/grpc/provider/access_token.rs +++ b/crates/openshell-server/src/grpc/provider/access_token.rs @@ -27,11 +27,17 @@ pub(in crate::grpc) async fn handle( request: Request, ) -> Result, Status> { let request_id = canonical_request_id(request.metadata()); + // Failures go to the authenticated sandbox's log stream; other callers + // are logged on the gateway only. + let log_sandbox_id = match request.extensions().get::() { + Some(Principal::Sandbox(principal)) => Some(principal.sandbox_id.clone()), + _ => None, + }; let result = obtain(state, request, request_id.as_deref()).await; result.map_err(|status| { let status = sanitize_token_grant_status(status); log_token_grant_failure( - None, + log_sandbox_id.as_deref(), request_id.as_deref(), TokenGrantFailure::from_status(&status), ); diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index d07bd7cd3d..8eca6d18cc 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -30,8 +30,9 @@ API socket. That resolver owns the instance's token cache and HTTP client and uses the gateway only for the intermediate exchange of RFC 8693 grants. Without a socket, its grants fail closed. Shared sandbox instances use `GatewayTokenGrantResolver`, which holds only the instance's authenticated -`GatewayEndpoint`. Each admitted operation forwards a configured dynamic -credential key and request ID; the gateway derives the sandbox identity, +gateway channel, connected on the first grant and reused. Each admitted +operation forwards a configured dynamic credential key and request ID; the +gateway derives the sandbox identity, validates current authorization, resolves grant parameters, and obtains the final access token, which the resolver validates. No SPIRE client, SVID, intermediate token, or grant cache is used in the shared supervisor. `mise run test:spire-delegation` exercises the gateway's diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 5c6a91a179..748c5a4863 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -47,7 +47,7 @@ use std::pin::Pin; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use openshell_core::grpc_client::GatewayEndpoint; +use openshell_core::grpc_client::{GatewayEndpoint, SandboxProviderTokenClient}; use openshell_core::oauth::{ self, ACCESS_TOKEN_TYPE, OAuthTokenResponse, TokenExchangeParams, TokenGrantClient, TokenGrantParams, effective_client_assertion_type, effective_token_type, token_cache_ttl, @@ -290,17 +290,19 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { /// supervisor holds no workload identity of its own. /// /// It forwards only the credential key and request ID over the instance's -/// authenticated gateway connection and validates the returned token. The -/// gateway authorizes, acquires, exchanges, and caches; this resolver holds -/// no cache, SVID, intermediate token, or HTTP client. +/// reused authenticated gateway connection and validates the returned token. +/// The gateway authorizes, acquires, exchanges, and caches on every request; +/// this resolver holds no cache, SVID, intermediate token, or HTTP client. pub struct GatewayTokenGrantResolver { - gateway: GatewayEndpoint, + gateway: SandboxProviderTokenClient, } impl GatewayTokenGrantResolver { #[must_use] pub fn new(gateway: GatewayEndpoint) -> Self { - Self { gateway } + Self { + gateway: SandboxProviderTokenClient::new(gateway), + } } } @@ -310,12 +312,10 @@ impl TokenGrantResolver for GatewayTokenGrantResolver { request: TokenGrantRequest<'a>, ) -> Pin> + Send + 'a>> { Box::pin(async move { - let token = openshell_core::grpc_client::get_sandbox_provider_access_token( - &self.gateway, - request.provider_key, - Some(request.request_id), - ) - .await?; + let token = self + .gateway + .get_access_token(request.provider_key, Some(request.request_id)) + .await?; validate_access_token(&token.access_token)?; Ok(token.access_token) }) From 4a8efe1ce984d9579af7dae87bff22ad6b0ad7bb Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 16:46:01 -0700 Subject: [PATCH 4/8] test(provider): satisfy broker logging lint checks Signed-off-by: Drew Newberry --- crates/openshell-server/src/grpc/provider.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 8d192ff06b..606139ad06 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5744,9 +5744,7 @@ mod tests { .with_writer(move || writer.clone()) .finish(); let capture = crate::otel_tracing::test_exporter::install_scoped(subscriber); - let error = crate::grpc::provider::access_token::handle(&state, request) - .await - .unwrap_err(); + let error = access_token::handle(&state, request).await.unwrap_err(); drop(capture); let failure = assert_generic(&error, Code::Unauthenticated); From 4686ac0cd883391d2dd34d5e5182b04357e8f5d5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 19:25:11 -0700 Subject: [PATCH 5/8] fix(provider): harden broker caching and deployment prerequisites Signed-off-by: Drew Newberry --- .agents/skills/helm-dev-environment/SKILL.md | 11 + crates/openshell-core/src/config.rs | 6 + .../src/dynamic_credential_key.rs | 37 + crates/openshell-core/src/grpc_client.rs | 94 ++- crates/openshell-core/src/oauth.rs | 7 +- .../openshell-core/src/token_grant_failure.rs | 27 + crates/openshell-server/README.md | 29 +- crates/openshell-server/src/grpc/provider.rs | 41 +- .../src/grpc/provider/access_token.rs | 635 ++++++++++++++---- .../openshell-server/src/spiffe_delegation.rs | 3 +- .../src/token_grant.rs | 2 +- .../ci/values-supervisor-spiffe.yaml | 7 + deploy/helm/openshell/templates/NOTES.txt | 29 + .../openshell/templates/_gateway-workload.tpl | 6 + deploy/helm/openshell/tests/notes_test.yaml | 27 + deploy/helm/openshell/values.yaml | 6 + .../kubernetes_shared_supervisor_spiffe.rs | 44 +- skills/debug-openshell-cluster/SKILL.md | 10 + 18 files changed, 822 insertions(+), 199 deletions(-) create mode 100644 deploy/helm/openshell/templates/NOTES.txt create mode 100644 deploy/helm/openshell/tests/notes_test.yaml diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 4f35570d7a..23f8c800fe 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -104,6 +104,17 @@ identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation` checks delegated issuance and identity selection against a local SPIRE server and agent without a cluster; the Kubernetes test covers the gateway broker. +The fixture requires admission that permits the admin socket `hostPath` and +supplemental group; an unlabeled namespace can still inherit cluster-wide +restrictions. The test registers sandboxes with the agent on the gateway's +node only. Real deployments need a Pod Security +exception or custom admission policy (Baseline and Restricted forbid it), +OpenShift must admit `admin_socket_group` as a supplemental group, the agent +must create the socket directory before the gateway starts (`type: Directory` +is intentional), and registrations must cover every agent that may run a +gateway Pod, for example with a SPIRE node alias. Limiting replicas does not +cover rescheduling. The chart's install notes list these prerequisites. + ### 2. Deploy OpenShell **Iterative dev** (rebuilds on file changes, recommended during active development): diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 195c72cea4..3992712eb7 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -211,6 +211,12 @@ pub fn normalize_compute_driver_name(value: &str) -> Result { pub struct SpiffeDelegationConfig { pub admin_socket_path: String, pub spiffe_id_prefix: String, + /// Supplementary group permitted to open the admin socket. + /// + /// The gateway validates it but + /// never changes its own groups; the Helm chart adds it to the gateway + /// Pod's `supplementalGroups`, and other deployments must run the gateway + /// with it. #[serde(default, skip_serializing_if = "Option::is_none")] pub admin_socket_group: Option, } diff --git a/crates/openshell-core/src/dynamic_credential_key.rs b/crates/openshell-core/src/dynamic_credential_key.rs index 8211b6267c..2dbc2bc939 100644 --- a/crates/openshell-core/src/dynamic_credential_key.rs +++ b/crates/openshell-core/src/dynamic_credential_key.rs @@ -109,6 +109,27 @@ pub fn credential_identity(key: &str) -> Option<&str> { Some(decode(key)?.1) } +/// The key a gateway encoded, recovered from an owned key the supervisor may +/// have scoped with [`ProviderCredentialSnapshot::scoped_key`]. +/// +/// Only the revision and installation are removed, so their spelling never +/// distinguishes two keys. Keys without an endpoint selector or owner, and +/// malformed keys, return `None`. The result still names a configured +/// credential only if it equals one of the gateway's keys exactly. +#[must_use] +pub fn unscoped_owned_key(key: &str) -> Option { + decode(key)?.0?; + let fields = key.split('\t').collect::>(); + match fields.as_slice() { + [selector @ .., owner, identity] | [selector @ .., owner, _, _, identity] + if selector.len() == 3 => + { + Some(format!("{}\t{owner}\t{identity}", selector.join("\t"))) + } + _ => None, + } +} + fn decode(key: &str) -> Option<(Option>, &str)> { // A stack buffer bounds parsing and avoids allocating on each request. let mut fields = [""; 7]; @@ -314,6 +335,22 @@ mod tests { Some("/v1/**") ); assert_eq!(credential_identity(&scoped), Some("provider:access_token")); + // Only the generation is removed; its spelling cannot select another key. + assert_eq!(unscoped_owned_key(&scoped), Some(key())); + assert_eq!(unscoped_owned_key(&key()), Some(key())); + assert_eq!( + unscoped_owned_key(&scoped.replace("rev:7", "rev:007")), + Some(key()) + ); + for unowned in [ + "provider:access_token", + "rev:7\tinstallation:install\tprovider:access_token", + "api.example.com\t443\t/v1/**\tprovider:access_token", + "api.example.com\t443\t/v1/**\trev:7\tinstallation:install\tprovider:access_token", + "api.example.com\t443\t/v1/**\towner-a\trev:x\tinstallation:install\tprovider:access_token", + ] { + assert_eq!(unscoped_owned_key(unowned), None, "{unowned:?}"); + } assert_eq!( snapshot.scoped_key("provider:access_token"), "rev:7\tinstallation:install\tprovider:access_token" diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index d9c28ba21e..7acaf84337 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1587,56 +1587,73 @@ pub async fn exchange_provider_subject_token( /// request reads the instance's current bearer, so renewal and revocation /// apply without reconnecting, and the gateway authorizes every request. pub struct SandboxProviderTokenClient { - gateway: GatewayEndpoint, + // The validated endpoint shares the instance's rotating credentials. + gateway: std::result::Result<(url::Url, GatewayEndpoint), TokenGrantFailure>, client: tokio::sync::OnceCell>, + deadline: Duration, } impl SandboxProviderTokenClient { #[must_use] pub fn new(gateway: GatewayEndpoint) -> Self { - Self { + Self::with_deadline( gateway, + crate::token_grant_failure::BROKERED_TOKEN_CLIENT_DEADLINE, + ) + } + + fn with_deadline(gateway: GatewayEndpoint, deadline: Duration) -> Self { + Self { + gateway: token_grant_gateway_endpoint(gateway.url()).map(|endpoint| { + let gateway = gateway.with_url(endpoint.as_str()); + (endpoint, gateway) + }), client: tokio::sync::OnceCell::new(), + deadline, } } /// Obtain only the final access token; the gateway selects and verifies /// the sandbox identity and resolves all grant parameters from its /// configuration. + /// + /// One deadline bounds waiting for and connecting the channel as well as + /// the call, so a failed connection cannot serialize later requests. #[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)] pub async fn get_access_token( &self, credential_key: &str, request_id: Option<&str>, ) -> std::result::Result { - let endpoint = token_grant_gateway_endpoint(self.gateway.url())?; + let (endpoint, gateway) = self.gateway.as_ref().map_err(|failure| *failure)?; // Like connect(), refuse locally revoked sandbox instance credentials // before the call; the response boundary rejects them again. let unavailable = || gateway_grant_failure(TokenGrantFailureKind::Unavailable); - if self.gateway.credentials().is_revoked() { + if gateway.credentials().is_revoked() { return Err(unavailable()); } - let mut client = self - .client - .get_or_try_init(|| async { connect(&self.gateway.with_url(endpoint.as_str())).await }) - .await - .map_err(|_| unavailable())? - .clone(); let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest { credential_key: credential_key.to_string(), }); if let Some(value) = request_id.and_then(|value| value.parse().ok()) { request.metadata_mut().insert("x-request-id", value); } - let response = tokio::time::timeout( - Duration::from_mins(1), - client.get_sandbox_provider_access_token(request), - ) + let response = tokio::time::timeout(self.deadline, async { + let mut client = self + .client + .get_or_try_init(|| connect(gateway)) + .await + .map_err(|_| unavailable())? + .clone(); + client + .get_sandbox_provider_access_token(request) + .await + .map_err(|status| provider_subject_token_exchange_status(&status, endpoint)) + }) .await - .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))? - .map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))? + .map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))?? .into_inner(); - if self.gateway.credentials().is_revoked() { + if gateway.credentials().is_revoked() { return Err(gateway_grant_failure( TokenGrantFailureKind::WorkloadIdentityRejected, )); @@ -1775,6 +1792,49 @@ mod token_grant_exchange_tests { assert!(token_grant_gateway_endpoint("https://localhost:8443").is_ok()); } + #[tokio::test] + async fn access_token_deadline_bounds_connection_and_call_for_each_request() { + // The gateway accepts connections but never answers. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let held = tokio::spawn(async move { + let (_connection, _) = listener.accept().await.unwrap(); + std::future::pending::<()>().await; + }); + let credentials = GatewayCredentials::new( + AsciiMetadataValue::from_static("Bearer test"), + TokenSource::Env, + ); + let deadline = Duration::from_millis(300); + let client = SandboxProviderTokenClient::with_deadline( + GatewayEndpoint::new(format!("http://{address}"), credentials), + deadline, + ); + let started = tokio::time::Instant::now(); + let results: [_; 2] = tokio::join!( + client.get_access_token("key", None), + client.get_access_token("key", None), + ) + .into(); + for result in results { + let failure = result.err().expect("unanswered gateway times out"); + assert_eq!(failure.kind(), TokenGrantFailureKind::Timeout); + } + // Concurrent requests share one budget rather than queueing behind it. + assert!(started.elapsed() < deadline * 3); + held.abort(); + + let invalid = SandboxProviderTokenClient::new(GatewayEndpoint::new( + "https://localhost/SECRET", + GatewayCredentials::new( + AsciiMetadataValue::from_static("Bearer test"), + TokenSource::Env, + ), + )); + let failure = invalid.get_access_token("key", None).await.err().unwrap(); + assert_eq!(failure.kind(), TokenGrantFailureKind::Configuration); + } + #[test] fn gateway_response_keeps_expiration_and_rejects_invalid_duration() { let response = |seconds, nanos| crate::proto::ExchangeProviderSubjectTokenResponse { diff --git a/crates/openshell-core/src/oauth.rs b/crates/openshell-core/src/oauth.rs index 58ae2fa4dd..8e31239d97 100644 --- a/crates/openshell-core/src/oauth.rs +++ b/crates/openshell-core/src/oauth.rs @@ -15,7 +15,8 @@ use std::time::Duration; use serde::Deserialize; use crate::token_grant_failure::{ - REQUEST_ID_HEADER, TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, + REQUEST_ID_HEADER, TOKEN_SERVICE_REQUEST_TIMEOUT, TokenGrantFailure, TokenGrantFailureKind, + TokenGrantStage, }; pub const DEFAULT_CLIENT_ASSERTION_TYPE: &str = @@ -57,8 +58,8 @@ impl TokenGrantClient { // idempotent for this caller and never relaxes peer authentication. openshell_crypto::tls::ensure_default_provider(); reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .connect_timeout(Duration::from_secs(30)) + .timeout(TOKEN_SERVICE_REQUEST_TIMEOUT) + .connect_timeout(TOKEN_SERVICE_REQUEST_TIMEOUT) .no_proxy() .redirect(reqwest::redirect::Policy::none()) } diff --git a/crates/openshell-core/src/token_grant_failure.rs b/crates/openshell-core/src/token_grant_failure.rs index d469047a75..358cf1c31f 100644 --- a/crates/openshell-core/src/token_grant_failure.rs +++ b/crates/openshell-core/src/token_grant_failure.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::fmt; +use std::time::Duration; use prost::Message; use tonic::{Code, Status}; @@ -22,6 +23,32 @@ use crate::rpc_error::{ERROR_DOMAIN, ErrorDetails, StatusExt}; /// random ID and grants nothing. pub const REQUEST_ID_HEADER: &str = "x-openshell-request-id"; +// Time limits of a grant. Exceeding one is a `Timeout` failure. + +/// One token-service request, including its connection. +pub const TOKEN_SERVICE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// One SPIFFE Workload API or SPIRE agent call, including its connection. +pub const WORKLOAD_IDENTITY_CALL_TIMEOUT: Duration = Duration::from_secs(10); + +/// One gateway-brokered provider access token request on the gateway, +/// including waiting for a concurrent acquisition of the same token. +/// +/// A miss makes at most three SPIRE agent calls for the sandbox SVID, one +/// Workload API acquisition of the gateway's own SVID, and two token-service +/// requests. The remainder covers authorization and store reads. +pub const BROKERED_TOKEN_GATEWAY_DEADLINE: Duration = Duration::from_secs( + 4 * WORKLOAD_IDENTITY_CALL_TIMEOUT.as_secs() + 2 * TOKEN_SERVICE_REQUEST_TIMEOUT.as_secs() + 20, +); + +/// Deadline for one brokered token request on the supervisor. +/// +/// Covers waiting for and connecting the gateway channel (bounded at 10 +/// seconds), then the gateway's deadline, so its classified timeout normally +/// arrives first. +pub const BROKERED_TOKEN_CLIENT_DEADLINE: Duration = + Duration::from_secs(BROKERED_TOKEN_GATEWAY_DEADLINE.as_secs() + 15); + const FAILURE_MESSAGE: &str = "dynamic token grant failed"; const FAILURE_REASON: &str = "TOKEN_GRANT_FAILURE"; const MAX_DETAILS_BYTES: usize = 1024; diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index ad9fb64340..097ef0a914 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -91,15 +91,38 @@ fixture at an existing deployment database. acquisition for the Kubernetes shared supervisor. The private admin socket is mounted only in the gateway. `GetSandboxProviderAccessToken` accepts an endpoint-bound configured credential key; sandbox identity comes from the -verified, currently authorized session JWT. Provider attachment and profile +verified, currently authorized session JWT. The key must equal one of the +gateway's configured keys, including its endpoint owner, once the supervisor's +revision and installation are removed. Provider attachment and profile configuration determine the endpoint, audience, and scopes. The gateway performs RFC 7523 or both RFC 8693 exchanges and returns only the final access token. Its bounded cache is isolated by sandbox generation, credential epoch, provider -revision, and grant. The session is authorized again around each SPIRE and +revision, the matched configured key and grant, and the subject token, which is +resolved before every lookup so its revocation or rotation also applies to +cached tokens. Concurrent misses for one cache key wait for a single +acquisition, then use its cached token. A token is served until the usual skew +before the earliest of its `expires_in`, its JWT `exp`, and the session, SVID, +and subject expiries, and no longer than the default cache lifetime; a +configured cache TTL can only shorten that. The response reports the remaining +lifetime. The session is authorized again around each SPIRE and token-service call. A cache hit and a fresh token then pass the same final check: the grant configuration still matches and the session is still authorized. A fresh token is cached only after that check. Startup validates the delegation settings, and gateway state construction parses them once and -returns a configuration error when they are invalid; each request still fetches +returns a configuration error when they are invalid; each cache miss fetches a new SVID and the agent's current JWT bundle. SPIRE still trusts the gateway as a delegate for its agent's available registration scope. + +`openshell_core::token_grant_failure` defines the time limits: SPIRE and +Workload API calls, token-service requests, the gateway's deadline for one +request (including waiting for a concurrent acquisition), and the longer +supervisor deadline that covers connecting to the gateway. + +Deployments must admit the admin socket mount: the chart uses a hostPath +volume, which Pod Security Baseline and Restricted forbid, so the namespace +needs a policy exception or custom admission policy, and OpenShift must admit +`admin_socket_group`. The gateway never changes its own groups; outside the +chart, run it with that group. The SPIRE agent must create the socket directory +before the gateway Pod starts. Sandbox registrations must cover every agent +that may run a gateway Pod, for example through a node alias; a gateway on a +node whose agent lacks them fails every grant. diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 606139ad06..930c2cbb50 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2579,7 +2579,7 @@ use openshell_core::oauth::{ effective_token_type, }; use openshell_core::token_grant_failure::{ - TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, + TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, WORKLOAD_IDENTITY_CALL_TIMEOUT, }; const DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 300; const MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 3600; @@ -2616,17 +2616,23 @@ impl IntermediateTokenCache { } fn get(&self, key: &str) -> Option { + self.get_until(key).map(|(token, _)| token) + } + + /// The unexpired token with the time its entry expires, in milliseconds. + fn get_until(&self, key: &str) -> Option<(oauth::OAuthTokenResponse, i64)> { let now_ms = crate::persistence::current_time_ms(); let tokens = self.tokens.read().ok()?; let cached = tokens.get(key)?; if cached.expires_at_ms <= now_ms { return None; } - Some(oauth::OAuthTokenResponse { + let token = oauth::OAuthTokenResponse { access_token: cached.access_token.clone(), expires_in: cached.expires_at_ms.saturating_sub(now_ms) / 1000, token_type: cached.token_type.clone(), - }) + }; + Some((token, cached.expires_at_ms)) } fn set(&self, key: String, token: &oauth::OAuthTokenResponse, expires_at_ms: i64) { @@ -4660,13 +4666,28 @@ async fn fetch_gateway_jwt_svid(audience: &str) -> Result { .filter(|value| !value.trim().is_empty()) .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); - let client = spiffe::WorkloadApiClient::connect_to(&endpoint) - .await - .map_err(|error| token_grant_workload_api_status(error, tonic::Code::FailedPrecondition))?; - client - .fetch_jwt_token([audience], None) - .await - .map_err(|error| token_grant_workload_api_status(error, tonic::Code::FailedPrecondition)) + // A wedged Workload API socket must not hold the grant past its deadline. + tokio::time::timeout(WORKLOAD_IDENTITY_CALL_TIMEOUT, async { + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|error| { + token_grant_workload_api_status(error, tonic::Code::FailedPrecondition) + })?; + client + .fetch_jwt_token([audience], None) + .await + .map_err(|error| { + token_grant_workload_api_status(error, tonic::Code::FailedPrecondition) + }) + }) + .await + .map_err(|_| { + TokenGrantFailure::new( + TokenGrantFailureKind::Timeout, + TokenGrantStage::WorkloadIdentity, + ) + .to_status() + })? } fn validate_gateway_jwt_svid_claims( diff --git a/crates/openshell-server/src/grpc/provider/access_token.rs b/crates/openshell-server/src/grpc/provider/access_token.rs index 87ee87fd63..8e75774d12 100644 --- a/crates/openshell-server/src/grpc/provider/access_token.rs +++ b/crates/openshell-server/src/grpc/provider/access_token.rs @@ -6,21 +6,24 @@ use super::{ Arc, HashMap, IntermediateTokenCache, LazyLock, Message, ObjectId, ObjectWorkspace, Principal, Provider, ProviderCredentialTokenGrantType, Request, Response, Sandbox, ServerState, Status, - TokenExchangeParams, TokenGrantFailure, canonical_request_id, effective_jwt_svid_audience, - fetch_gateway_jwt_svid, get_provider_type_profile_for_scope, - insert_dynamic_credentials_for_profile, log_token_grant_failure, oauth, - perform_intermediate_token_exchange, provider_credential_expiration_ms, - resolve_subject_token_credential, sanitize_token_grant_status, token_exchange_http_client, - token_grant_configuration_status, token_grant_dependency_status, token_grant_identity_rejected, - token_grant_store_status, + TokenExchangeParams, TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, + canonical_request_id, effective_jwt_svid_audience, fetch_gateway_jwt_svid, + get_provider_type_profile_for_scope, insert_dynamic_credentials_for_profile, jwt_exp_ms, + log_token_grant_failure, oauth, perform_intermediate_token_exchange, + provider_credential_expiration_ms, resolve_subject_token_credential, + sanitize_token_grant_status, token_exchange_http_client, token_grant_configuration_status, + token_grant_dependency_status, token_grant_identity_rejected, token_grant_store_status, }; use openshell_core::jwt::AuthenticatedSandboxSession; use openshell_core::proto::{ GetSandboxProviderAccessTokenRequest, GetSandboxProviderAccessTokenResponse, ProviderCredentialTokenGrant, }; +use openshell_core::token_grant_failure::BROKERED_TOKEN_GATEWAY_DEADLINE; +use std::time::Duration; static ACCESS_TOKENS: LazyLock = LazyLock::new(IntermediateTokenCache::new); +static ACQUISITIONS: LazyLock = LazyLock::new(Acquisitions::default); pub(in crate::grpc) async fn handle( state: &Arc, @@ -33,7 +36,18 @@ pub(in crate::grpc) async fn handle( Some(Principal::Sandbox(principal)) => Some(principal.sandbox_id.clone()), _ => None, }; - let result = obtain(state, request, request_id.as_deref()).await; + // The deadline also bounds waiting for a concurrent acquisition. + let result = tokio::time::timeout( + BROKERED_TOKEN_GATEWAY_DEADLINE, + obtain(state, request, request_id.as_deref()), + ) + .await + .unwrap_or_else(|_| { + Err( + TokenGrantFailure::new(TokenGrantFailureKind::Timeout, TokenGrantStage::GatewayRpc) + .to_status(), + ) + }); result.map_err(|status| { let status = sanitize_token_grant_status(status); log_token_grant_failure( @@ -67,19 +81,30 @@ async fn authorize( Ok(session) } -/// Resolve the exact endpoint selector from the gateway's approved profile. +/// One configured credential of an attached provider. +struct ConfiguredGrant { + provider: Provider, + grant: ProviderCredentialTokenGrant, + /// The gateway's exact key for the credential, including its endpoint owner. + key: String, +} + +/// Resolve the exact configured credential from the gateway's approved profile. /// Audience and scopes (including endpoint overrides) never come from the caller. +/// +/// The caller's key must name a configured key exactly, apart from the +/// generation a supervisor adds; a different or missing owner matches nothing. async fn grant_configuration( state: &ServerState, sandbox_id: &str, key: &str, -) -> Result<(Provider, ProviderCredentialTokenGrant), Status> { +) -> Result { let invalid = || token_grant_configuration_status(tonic::Code::PermissionDenied); + let key = + openshell_core::dynamic_credential_key::unscoped_owned_key(key).ok_or_else(invalid)?; let identity = - openshell_core::dynamic_credential_key::credential_identity(key).ok_or_else(invalid)?; + openshell_core::dynamic_credential_key::credential_identity(&key).ok_or_else(invalid)?; let (provider_name, _) = identity.split_once(':').ok_or_else(invalid)?; - let selector = - openshell_core::dynamic_credential_key::endpoint_selector(key).ok_or_else(invalid)?; let sandbox = state .store .get_message::(sandbox_id) @@ -114,49 +139,65 @@ async fn grant_configuration( provider_name, provider.object_id(), )?; - let mut matching = configured.iter().filter(|(candidate, _)| { - let Some(endpoint) = openshell_core::dynamic_credential_key::endpoint_selector(candidate) - else { - return false; - }; - endpoint.host == selector.host - && endpoint.port == selector.port - && endpoint.path == selector.path - && openshell_core::dynamic_credential_key::credential_identity(candidate) - == Some(identity) - }); - let (_, credential) = matching.next().ok_or_else(invalid)?; - if matching.next().is_some() { - return Err(invalid()); - } - let grant = credential.token_grant.clone().ok_or_else(invalid)?; + let credential = configured.remove(&key).ok_or_else(invalid)?; + let grant = credential.token_grant.ok_or_else(invalid)?; oauth::parse_token_endpoint_url(&grant.token_endpoint).map_err(TokenGrantFailure::to_status)?; - Ok((provider, grant)) + Ok(ConfiguredGrant { + provider, + grant, + key, + }) } +/// Bind a cached token to its runtime, configured credential, and subject. +/// +/// Include the provider revision and resolved subject token, so an in-place +/// rotation of a handle-backed subject never serves a token minted from the +/// previous one. The digest is an in-memory map key and is never exposed. fn cache_key( session: &AuthenticatedSandboxSession, - provider: &Provider, - grant: &ProviderCredentialTokenGrant, - credential_key: &str, + configured: &ConfiguredGrant, + subject: Option<&str>, gateway_id: &str, ) -> Result { - use sha2::{Digest as _, Sha256}; - let binding = serde_json::to_vec(&( - gateway_id, - session.sandbox_id.as_str(), - session.runtime_generation.as_str(), - session.auth_epoch.get(), - provider.object_id(), - provider - .metadata - .as_ref() - .map(|metadata| metadata.resource_version), - credential_key, - Message::encode_to_vec(grant), - )) - .map_err(|_| token_grant_configuration_status(tonic::Code::Internal))?; - Ok(hex::encode(Sha256::digest(binding))) + let resource_version = configured + .provider + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + let fields: [&[u8]; 11] = [ + b"openshell-provider-access-token-v1", + gateway_id.as_bytes(), + session.sandbox_id.as_str().as_bytes(), + session.runtime_generation.as_str().as_bytes(), + &session.auth_epoch.get().to_be_bytes(), + configured.provider.object_id().as_bytes(), + &resource_version.to_be_bytes(), + configured.key.as_bytes(), + &Message::encode_to_vec(&configured.grant), + &[u8::from(subject.is_some())], + subject.map_or(&[], str::as_bytes), + ]; + digest_fields(&fields) + .map(hex::encode) + .map_err(|_| token_grant_configuration_status(tonic::Code::Internal)) +} + +/// SHA-256 of length-prefixed fields, so no two field lists share an encoding. +fn digest_fields(fields: &[&[u8]]) -> Result<[u8; 32], openshell_crypto::CryptoError> { + let mut digest = openshell_crypto::sha256_digest()?; + for field in fields { + digest.update(&u64::try_from(field.len()).unwrap_or(u64::MAX).to_be_bytes())?; + digest.update(field)?; + } + digest.finish() +} + +/// The subject token of a token exchange grant, resolved for this request. +struct Subject { + value: String, + token_type: String, + expires_at_ms: Option, } #[allow(clippy::too_many_lines)] @@ -193,8 +234,8 @@ async fn obtain( } let gateway_id = delegation.gateway_id(); let key = &request.get_ref().credential_key; - let (provider, grant) = grant_configuration(state, session.sandbox_id.as_str(), key).await?; - let fingerprint = cache_key(&session, &provider, &grant, key, gateway_id)?; + let configured = grant_configuration(state, session.sandbox_id.as_str(), key).await?; + let grant = &configured.grant; let ttl_override = grant .cache_ttl .as_ref() @@ -203,134 +244,295 @@ async fn obtain( .map_err(|_| token_grant_configuration_status(tonic::Code::InvalidArgument))?; let grant_type = ProviderCredentialTokenGrantType::try_from(grant.grant_type) .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; - let mut subject_expiry = None; + // Resolve the subject before any cache lookup, so its revocation or + // expiry also denies cached tokens. let subject = if grant_type == ProviderCredentialTokenGrantType::TokenExchange { let subject = grant .subject_token .as_ref() .filter(|subject| subject.source == "provider_credential") .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; - subject_expiry = provider_credential_expiration_ms(&provider, &subject.credential)?; - Some( - resolve_subject_token_credential(&state.credentials, &provider, &subject.credential) - .await?, - ) + Some(Subject { + expires_at_ms: provider_credential_expiration_ms( + &configured.provider, + &subject.credential, + )?, + value: resolve_subject_token_credential( + &state.credentials, + &configured.provider, + &subject.credential, + ) + .await?, + token_type: subject.subject_token_type.clone(), + }) } else { None }; - let cached = if ttl_override == Some(std::time::Duration::ZERO) { - None - } else { - ACCESS_TOKENS.get(&fingerprint) + let subject_value = subject.as_ref().map(|subject| subject.value.as_str()); + let fingerprint = cache_key(&session, &configured, subject_value, gateway_id)?; + // A zero override disables caching, and with it shared acquisition. + let caching = ttl_override != Some(Duration::ZERO); + let cached = || { + caching + .then(|| ACCESS_TOKENS.get_until(&fingerprint)) + .flatten() }; - // A miss also yields when its token may be cached; it is inserted only - // after the final validation below, which a hit passes as well. - let (response, cache_until) = if let Some(cached) = cached { - (cached, None) + // A miss holds its fingerprint's turn until its token is cached, so + // concurrent misses wait and then find it. A miss is cached only after + // the final validation below, which a hit passes as well. + let mut turn = None; + let (response, serve_until_ms, acquired) = if let Some((token, until)) = cached() { + (token, until, false) } else { - authorize(state, &bearer).await?; - let audience = effective_jwt_svid_audience(&grant.token_endpoint, &grant.jwt_svid_audience); - let (svid, svid_expiry) = delegation - .fetch_jwt_svid(session.sandbox_id.as_str(), &audience) - .await - .map_err(TokenGrantFailure::to_status)?; - authorize(state, &bearer).await?; - let client = token_exchange_http_client()?; - let token = if let Some(subject) = subject { - let component_svid = fetch_gateway_jwt_svid(&audience).await?; - let stable_id = delegation - .sandbox_spiffe_id(session.sandbox_id.as_str()) - .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; - let intermediate = perform_intermediate_token_exchange( - &grant.token_endpoint, - &TokenExchangeParams { - client_assertion: &component_svid, - client_assertion_type: &grant.client_assertion_type, - subject_token: &subject, - subject_token_type: &grant - .subject_token - .as_ref() - .ok_or_else(|| { - token_grant_configuration_status(tonic::Code::FailedPrecondition) - })? - .subject_token_type, - audience: &stable_id, - scopes: &[], - requested_token_type: &grant.requested_token_type, - }, - request_id, - ) - .await?; - authorize(state, &bearer).await?; - oauth::post_oauth_token_exchange( - client, - &grant.token_endpoint, - &TokenExchangeParams { - client_assertion: &svid, - client_assertion_type: &grant.client_assertion_type, - subject_token: &intermediate.access_token, - subject_token_type: oauth::ACCESS_TOKEN_TYPE, - audience: &grant.audience, - scopes: &grant.scopes, - requested_token_type: &grant.requested_token_type, - }, - request_id, - ) - .await - .map_err(TokenGrantFailure::to_status)? + let waited = if caching { + Some(ACQUISITIONS.turn(&fingerprint).await) } else { - oauth::post_oauth_token_grant( - client, - &grant.token_endpoint, - &oauth::TokenGrantParams { - client_assertion: &svid, - client_assertion_type: &grant.client_assertion_type, - audience: &grant.audience, - scopes: &grant.scopes, - }, + None + }; + if let Some((token, until)) = cached() { + (token, until, false) + } else { + turn = waited; + let (token, until) = acquire( + state, + &bearer, + &session, + delegation, + grant, + subject.as_ref(), + ttl_override, request_id, ) - .await - .map_err(TokenGrantFailure::to_status)? - }; - authorize(state, &bearer).await?; - let cache_ttl = oauth::token_cache_ttl(ttl_override, token.expires_in); - let now = crate::persistence::current_time_ms(); - // Stop serving the token a second before the session, SVID, or subject expires. - let identity_expiry = session - .expires_at - .saturating_mul(1000) - .min(svid_expiry.saturating_mul(1000)) - .min(subject_expiry.unwrap_or(i64::MAX)) - .saturating_sub(1000); - let expires_at = now - .saturating_add(i64::try_from(cache_ttl.as_millis()).unwrap_or(i64::MAX)) - .min(identity_expiry); - (token, (expires_at > now).then_some(expires_at)) + .await?; + (token, until, true) + } }; // Re-read attachment, provider revision, and profile, then the session, // before serving or caching any token. - let (current_provider, current_grant) = - grant_configuration(state, session.sandbox_id.as_str(), key).await?; - if cache_key(&session, ¤t_provider, ¤t_grant, key, gateway_id)? != fingerprint { + let current = grant_configuration(state, session.sandbox_id.as_str(), key).await?; + if cache_key(&session, ¤t, subject_value, gateway_id)? != fingerprint { return Err(token_grant_configuration_status( tonic::Code::FailedPrecondition, )); } authorize(state, &bearer).await?; - if let Some(expires_at) = cache_until { - ACCESS_TOKENS.set(fingerprint, &response, expires_at); + // The lifetime runs from when the token service answered, so time spent + // in the final validation counts against it. + let remaining_ms = serve_until_ms.saturating_sub(crate::persistence::current_time_ms()); + if remaining_ms <= 0 { + return Err(TokenGrantFailure::new( + TokenGrantFailureKind::Timeout, + TokenGrantStage::GatewayRpc, + ) + .to_status()); + } + if acquired && caching { + ACCESS_TOKENS.set(fingerprint, &response, serve_until_ms); } + drop(turn); Ok(Response::new(GetSandboxProviderAccessTokenResponse { access_token: response.access_token, token_type: response.token_type, - expires_after: openshell_core::time::duration_from_std(std::time::Duration::from_secs( - u64::try_from(response.expires_in).unwrap_or_default(), + expires_after: openshell_core::time::duration_from_std(Duration::from_millis( + u64::try_from(remaining_ms).unwrap_or_default(), )) .ok(), })) } +/// Acquire a final token, re-authorizing the session between network calls. +/// Returns the token and when to stop serving it. +#[allow(clippy::too_many_arguments)] +async fn acquire( + state: &ServerState, + bearer: &str, + session: &AuthenticatedSandboxSession, + delegation: &crate::spiffe_delegation::SpiffeDelegation, + grant: &ProviderCredentialTokenGrant, + subject: Option<&Subject>, + ttl_override: Option, + request_id: Option<&str>, +) -> Result<(oauth::OAuthTokenResponse, i64), Status> { + authorize(state, bearer).await?; + let audience = effective_jwt_svid_audience(&grant.token_endpoint, &grant.jwt_svid_audience); + let (svid, svid_expiry) = delegation + .fetch_jwt_svid(session.sandbox_id.as_str(), &audience) + .await + .map_err(TokenGrantFailure::to_status)?; + authorize(state, bearer).await?; + let client = token_exchange_http_client()?; + let subject_expiry = subject.and_then(|subject| subject.expires_at_ms); + let token = if let Some(subject) = subject { + let component_svid = fetch_gateway_jwt_svid(&audience).await?; + let stable_id = delegation + .sandbox_spiffe_id(session.sandbox_id.as_str()) + .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + let intermediate = perform_intermediate_token_exchange( + &grant.token_endpoint, + &TokenExchangeParams { + client_assertion: &component_svid, + client_assertion_type: &grant.client_assertion_type, + subject_token: &subject.value, + subject_token_type: &subject.token_type, + audience: &stable_id, + scopes: &[], + requested_token_type: &grant.requested_token_type, + }, + request_id, + ) + .await?; + authorize(state, bearer).await?; + oauth::post_oauth_token_exchange( + client, + &grant.token_endpoint, + &TokenExchangeParams { + client_assertion: &svid, + client_assertion_type: &grant.client_assertion_type, + subject_token: &intermediate.access_token, + subject_token_type: oauth::ACCESS_TOKEN_TYPE, + audience: &grant.audience, + scopes: &grant.scopes, + requested_token_type: &grant.requested_token_type, + }, + request_id, + ) + .await + .map_err(TokenGrantFailure::to_status)? + } else { + oauth::post_oauth_token_grant( + client, + &grant.token_endpoint, + &oauth::TokenGrantParams { + client_assertion: &svid, + client_assertion_type: &grant.client_assertion_type, + audience: &grant.audience, + scopes: &grant.scopes, + }, + request_id, + ) + .await + .map_err(TokenGrantFailure::to_status)? + }; + let obtained_at_ms = crate::persistence::current_time_ms(); + let identity_expires_at_ms = session + .expires_at + .saturating_mul(1000) + .min(svid_expiry.saturating_mul(1000)) + .min(subject_expiry.unwrap_or(i64::MAX)); + let serve_until_ms = + serve_until_ms(&token, ttl_override, obtained_at_ms, identity_expires_at_ms).ok_or_else( + || { + TokenGrantFailure::new( + TokenGrantFailureKind::Unclassified, + TokenGrantStage::TokenEndpoint, + ) + .to_status() + }, + )?; + authorize(state, bearer).await?; + Ok((token, serve_until_ms)) +} + +/// When to stop serving a token the token service issued at `obtained_at_ms`, +/// or `None` when it is already expired. +/// +/// The issuer's `expires_in`, a parseable JWT `exp` of the access token, and +/// the session, SVID, and subject expiries each bound the token; `exp` can +/// only shorten it. Serving stops the usual skew before the earliest bound, +/// after at least one second, and never later than the default cache lifetime +/// (whose fallback applies when the issuer omits `expires_in`). A configured +/// cache TTL may shorten that lifetime but, unlike a dedicated supervisor's +/// cache, never extends it. +fn serve_until_ms( + token: &oauth::OAuthTokenResponse, + ttl_override: Option, + obtained_at_ms: i64, + identity_expires_at_ms: i64, +) -> Option { + let issuer_expires_at_ms = (token.expires_in > 0) + .then(|| obtained_at_ms.saturating_add(token.expires_in.saturating_mul(1000))); + let expires_at_ms = [issuer_expires_at_ms, jwt_exp_ms(&token.access_token)] + .into_iter() + .flatten() + .fold(identity_expires_at_ms, i64::min); + if expires_at_ms <= obtained_at_ms { + return None; + } + let default_ttl = oauth::token_cache_ttl(None, token.expires_in); + let ttl = ttl_override + .filter(|ttl| !ttl.is_zero()) + .map_or(default_ttl, |ttl| ttl.min(default_ttl)); + let skewed_expiry_ms = expires_at_ms + .saturating_sub(oauth::TOKEN_CACHE_EXPIRY_SKEW_SECONDS.saturating_mul(1000)) + .max(obtained_at_ms.saturating_add(1000).min(expires_at_ms)); + Some( + obtained_at_ms + .saturating_add(i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX)) + .min(skewed_expiry_ms), + ) +} + +type AcquisitionSlots = HashMap>, usize)>; + +/// Coordinate token acquisition separately for each cache fingerprint. +/// +/// Concurrent misses share a single acquisition without locking unrelated +/// tokens. An entry exists only +/// while a request holds or awaits its turn, including a cancelled wait. +#[derive(Default)] +struct Acquisitions { + slots: std::sync::Mutex, +} + +impl Acquisitions { + async fn turn(&self, key: &str) -> AcquisitionTurn<'_> { + let registration = { + let mut slots = self.lock(); + let (slot, users) = slots.entry(key.to_string()).or_default(); + *users += 1; + Registration { + acquisitions: self, + key: key.to_string(), + slot: slot.clone(), + } + }; + AcquisitionTurn { + _guard: registration.slot.clone().lock_owned().await, + _registration: registration, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, AcquisitionSlots> { + self.slots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +struct Registration<'a> { + acquisitions: &'a Acquisitions, + key: String, + slot: Arc>, +} + +impl Drop for Registration<'_> { + fn drop(&mut self) { + let mut slots = self.acquisitions.lock(); + if let Some((_, users)) = slots.get_mut(&self.key) { + *users -= 1; + if *users == 0 { + slots.remove(&self.key); + } + } + } +} + +/// Fields drop in order: the turn is released before its registration. +struct AcquisitionTurn<'a> { + _guard: tokio::sync::OwnedMutexGuard<()>, + _registration: Registration<'a>, +} + #[cfg(test)] mod tests { use super::*; @@ -359,4 +561,141 @@ mod tests { )); } } + + #[test] + fn fingerprint_binds_the_runtime_configured_key_and_resolved_subject() { + use openshell_core::jwt::{CredentialEpoch, SandboxId}; + let session = AuthenticatedSandboxSession { + sandbox_id: SandboxId::parse(uuid::Uuid::new_v4().to_string()).unwrap(), + runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( + "g1", + ) + .unwrap(), + auth_epoch: CredentialEpoch::new(1).unwrap(), + token_id: uuid::Uuid::new_v4(), + issued_at: 1, + expires_at: 2, + }; + let configured = |key: &str| ConfiguredGrant { + provider: Provider::default(), + grant: ProviderCredentialTokenGrant::default(), + key: key.to_string(), + }; + let owned = "api.example.com\t443\t/v1/**\towner\tprovider:access"; + let fingerprint = |session, key, subject| { + cache_key(session, &configured(key), subject, "gateway").unwrap() + }; + let base = fingerprint(&session, owned, Some("subject-a")); + // A renewed session token of the same runtime shares cached tokens. + let renewed = AuthenticatedSandboxSession { + token_id: uuid::Uuid::new_v4(), + expires_at: 3, + ..session.clone() + }; + assert_eq!(fingerprint(&renewed, owned, Some("subject-a")), base); + // A rotated subject, another credential, or another epoch does not. + assert_ne!(fingerprint(&session, owned, Some("subject-b")), base); + let spoofed = owned.replace("owner", "other"); + assert_ne!(fingerprint(&session, &spoofed, Some("subject-a")), base); + let rotated = AuthenticatedSandboxSession { + auth_epoch: CredentialEpoch::new(2).unwrap(), + ..session.clone() + }; + assert_ne!(fingerprint(&rotated, owned, Some("subject-a")), base); + assert_ne!( + fingerprint(&session, owned, None), + fingerprint(&session, owned, Some("")) + ); + } + + #[test] + fn served_lifetime_never_exceeds_the_token_or_identity() { + const NOW: i64 = 1_700_000_000_000; + let token = |expires_in, access_token: &str| oauth::OAuthTokenResponse { + access_token: access_token.to_string(), + expires_in, + token_type: String::new(), + }; + let jwt = |exp_ms: i64| { + use base64::Engine as _; + let claims = format!(r#"{{"exp":{}}}"#, exp_ms / 1000); + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims); + format!("e30.{claims}.sig") + }; + let hour = Some(Duration::from_hours(1)); + let identity = NOW + 86_400_000; + let serve = |token, ttl, identity| serve_until_ms(&token, ttl, NOW, identity); + // A longer cache override is capped by the issuer's lifetime and skew. + assert_eq!( + serve(token(300, "opaque"), hour, identity), + Some(NOW + 270_000) + ); + // A shorter override still applies. + let minute = Some(Duration::from_mins(1)); + assert_eq!( + serve(token(300, "opaque"), minute, identity), + Some(NOW + 60_000) + ); + // A JWT exp only shortens the lifetime. + let short = jwt(NOW + 120_000); + assert_eq!( + serve(token(300, &short), hour, identity), + Some(NOW + 90_000) + ); + let long = jwt(NOW + 7_200_000); + assert_eq!( + serve(token(300, &long), None, identity), + Some(NOW + 270_000) + ); + // An omitted expires_in keeps the default fallback, even with an override. + assert_eq!( + serve(token(0, "opaque"), hour, identity), + Some(NOW + 270_000) + ); + // Zero disables caching, not the bound on serving. + let zero = Some(Duration::ZERO); + assert_eq!( + serve(token(300, "opaque"), zero, identity), + Some(NOW + 270_000) + ); + // Identity expiry caps the lifetime with skew but still serves briefly. + assert_eq!( + serve(token(300, "opaque"), None, NOW + 60_000), + Some(NOW + 30_000) + ); + assert_eq!( + serve(token(300, "opaque"), None, NOW + 10_000), + Some(NOW + 1_000) + ); + assert_eq!( + serve(token(300, "opaque"), None, NOW + 500), + Some(NOW + 500) + ); + // An already expired acquisition is never served. + assert_eq!(serve(token(300, &jwt(NOW)), None, identity), None); + assert_eq!(serve(token(300, "opaque"), None, NOW), None); + } + + #[tokio::test] + async fn concurrent_misses_take_turns_per_fingerprint_and_leave_no_entries() { + let acquisitions: &'static Acquisitions = Box::leak(Box::default()); + let first = acquisitions.turn("a").await; + // Another fingerprint is independent. + drop(acquisitions.turn("b").await); + // A waiter for the same fingerprint blocks; cancelling it leaves only + // the holder's entry. + let waiting = tokio::time::timeout(Duration::from_millis(50), acquisitions.turn("a")); + assert!(waiting.await.is_err()); + assert_eq!( + acquisitions.lock().get("a").map(|(_, users)| *users), + Some(1) + ); + assert_eq!(acquisitions.lock().len(), 1); + let waiter = tokio::spawn(async move { drop(acquisitions.turn("a").await) }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + drop(first); + waiter.await.unwrap(); + assert!(acquisitions.lock().is_empty()); + } } diff --git a/crates/openshell-server/src/spiffe_delegation.rs b/crates/openshell-server/src/spiffe_delegation.rs index eb3b52fd34..34510830a9 100644 --- a/crates/openshell-server/src/spiffe_delegation.rs +++ b/crates/openshell-server/src/spiffe_delegation.rs @@ -6,11 +6,10 @@ use openshell_core::config::{Config, SpiffeDelegationConfig}; use openshell_core::spiffe::SandboxSpiffeIdPrefix; use openshell_core::token_grant_failure::{ TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, + WORKLOAD_IDENTITY_CALL_TIMEOUT as AGENT_CALL_TIMEOUT, }; use std::collections::HashMap; use std::path::PathBuf; -use std::time::Duration; -const AGENT_CALL_TIMEOUT: Duration = Duration::from_secs(10); /// Validated, immutable settings of gateway-owned SPIRE delegation. /// diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 748c5a4863..a4c587d3d4 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -91,7 +91,7 @@ impl TokenCache { } } - /// Forget every cached token. + /// Store a token with expiration time. fn set(&self, provider_name: String, access_token: String, expires_at_ms: i64) { if let Ok(mut tokens) = self.tokens.write() { tokens.insert( diff --git a/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml index 6d42f2becd..8af0fb3180 100644 --- a/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml +++ b/deploy/helm/openshell/ci/values-supervisor-spiffe.yaml @@ -5,6 +5,13 @@ # identity from the node's SPIRE agent (ci/values-spire-delegation-stack.yaml) # for provider token grants. The gateway keeps its own Workload API identity # for the RFC 8693 intermediate exchange. +# +# This fixture requires admission that permits the admin socket hostPath and +# group; an unlabeled namespace can inherit cluster-wide restrictions. The +# test registers sandboxes only with the agent on the gateway's node. A +# deployment needs a Pod Security exception or +# equivalent admission policy and registrations for every agent that may run a +# gateway Pod. gatewayConfig: openshell.drivers.kubernetes: supervisor_topology: gateway diff --git a/deploy/helm/openshell/templates/NOTES.txt b/deploy/helm/openshell/templates/NOTES.txt new file mode 100644 index 0000000000..f239578300 --- /dev/null +++ b/deploy/helm/openshell/templates/NOTES.txt @@ -0,0 +1,29 @@ +{{- /* +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +*/ -}} +{{- $delegation := get (.Values.gatewayConfig | default dict) "openshell.gateway.spiffe_delegation" | default dict -}} +{{- if $delegation }} +SPIRE delegation (openshell.gateway.spiffe_delegation) is enabled. Provider +token grants for shared-supervisor sandboxes need these prerequisites: + +1. Admission. The gateway Pod mounts the SPIRE agent admin socket directory + {{ dir (get $delegation "admin_socket_path") }} as a hostPath volume. Pod Security + Standards forbid hostPath at Baseline and Restricted, so namespace + {{ .Release.Namespace }} needs an explicit policy exception or a custom + admission policy for this volume, or an approved alternative mount. +{{- with get $delegation "admin_socket_group" }} + The Pod also joins supplemental group {{ . }}. Admission must allow it; on + OpenShift the restricted-v2 SCC rejects groups outside the namespace range. +{{- end }} + +2. Startup order. The volume has type Directory: the SPIRE agent must create + that directory before the gateway Pod starts. A missing directory leaves + the Pod in ContainerCreating. The agent must also serve the socket with + group access for the gateway before provider token grants can succeed. + +3. Registrations. The gateway obtains sandbox identities from the agent on its + own node. Register them for every SPIRE agent that may run a gateway Pod, + for example with a node alias or one entry per agent. A gateway Pod on a + node whose agent lacks the entries fails every grant. +{{- end }} diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 394ba968d8..df5b227691 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -309,6 +309,12 @@ spec: path: ca.crt {{- end }} {{- if $delegation }} + {{- /* + Pod Security Baseline and Restricted forbid hostPath; NOTES.txt lists the + admission, startup-order, and registration prerequisites. Keep Directory: + creating an empty directory would provide neither the agent's socket nor + its group permissions. + */}} - name: spire-agent-admin hostPath: path: {{ dir (get $delegation "admin_socket_path") | quote }} diff --git a/deploy/helm/openshell/tests/notes_test.yaml b/deploy/helm/openshell/tests/notes_test.yaml new file mode 100644 index 0000000000..d38b90b6fd --- /dev/null +++ b/deploy/helm/openshell/tests/notes_test.yaml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +suite: install notes +templates: + - templates/NOTES.txt +release: + name: openshell + namespace: gateway +tests: + - it: lists SPIRE delegation prerequisites + set: + gatewayConfig: + openshell.drivers.kubernetes: + supervisor_topology: gateway + openshell.gateway.spiffe_delegation: + admin_socket_path: /run/spire/admin/admin.sock + spiffe_id_prefix: spiffe://example.org/openshell + admin_socket_group: 10035 + asserts: + - matchRegexRaw: + pattern: "admin socket directory\\s+/run/spire/admin as a hostPath volume" + - matchRegexRaw: + pattern: "namespace\\s+gateway needs an explicit policy exception" + - matchRegexRaw: + pattern: "supplemental group 10035" + - matchRegexRaw: + pattern: "every SPIRE agent that may run a gateway Pod" diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 04fc1152a4..c3732b8579 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -329,6 +329,12 @@ affinity: {} gatewayConfig: openshell: version: 2 + # openshell.gateway.spiffe_delegation mounts the SPIRE agent admin socket + # directory into the gateway as a hostPath volume, which Pod Security + # Baseline and Restricted forbid. It needs an admission exception, the + # agent's socket directory before the gateway starts, an admitted + # admin_socket_group, and sandbox registrations for every agent that may run + # a gateway Pod. The install notes repeat these prerequisites. # Server configuration server: diff --git a/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs b/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs index 0c9aa579d4..8f594f1d60 100644 --- a/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs +++ b/e2e/rust/tests/kubernetes_shared_supervisor_spiffe.rs @@ -3,7 +3,7 @@ #![cfg(feature = "e2e-kubernetes-shared-supervisor-spiffe")] -//! Sandbox identities delegated by SPIRE to the shared supervisor. +//! Sandbox identities delegated by SPIRE to the gateway for a shared supervisor. //! //! The wrapper installs SPIRE (`ci/values-spire-delegation-stack.yaml`) and //! the gateway topology with SPIRE delegation @@ -11,12 +11,13 @@ //! sandbox identities as an operator would, with `ClusterStaticEntry` //! resources, and drives the existing RFC 8693 provider exchange: the gateway //! exchanges the stored subject token with its own SVID for the sandbox's -//! SPIFFE ID, and the shared supervisor exchanges that intermediate token with -//! the sandbox's delegated SVID. The token service is a host fixture reached +//! SPIFFE ID, then exchanges that intermediate token with the sandbox's +//! delegated SVID. The supervisor receives only the final access token. +//! The token service is a host fixture reached //! through a selectorless Service, and it hands out tokens that name the //! identity they were issued to. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::io::Write as _; use std::process::Stdio; use std::sync::{Arc, Mutex}; @@ -455,12 +456,18 @@ fn call_script(port: u16) -> String { r#" import urllib.error, urllib.request, uuid +def canonical_request_id(value): + try: + return str(uuid.UUID(value)) == value + except (TypeError, ValueError, AttributeError): + return False + try: with urllib.request.urlopen("http://{TEST_HOST}:{port}/resource", timeout=20) as response: print(response.read().decode()) except urllib.error.HTTPError as error: request_id = error.headers.get("x-openshell-request-id", "") - if error.code == 502 and str(uuid.UUID(request_id)) == request_id: + if error.code == 502 and canonical_request_id(request_id): print("DENIED") else: print(f"HTTP-{{error.code}}") @@ -636,10 +643,10 @@ async fn unregister_all() { // contain "spiffe" without exposing an identity socket or credential. fn assert_no_spiffe_access(pod: &serde_json::Value) { let spec = &pod["spec"]; - let containers = spec["containers"] - .as_array() - .unwrap() - .iter() + let containers = ["containers", "initContainers", "ephemeralContainers"] + .into_iter() + .filter_map(|field| spec[field].as_array()) + .flatten() .map(|container| { serde_json::json!({ "env": container["env"], @@ -778,13 +785,16 @@ async fn shared_supervisor_acts_as_each_sandboxs_stable_spiffe_identity() { .map(|exchange| exchange.assertion_subject.clone()) .collect::>() }; + // A retried exchange may repeat an identity, but only these two appear. let exchanges = tokens.exchanges(); + let subjects = finals(&exchanges); assert_eq!( - finals(&exchanges), - [ + subjects.iter().cloned().collect::>(), + BTreeSet::from([ spiffe_id(&gateway_id, &alpha_id), spiffe_id(&gateway_id, &beta_id) - ] + ]), + "final exchange subjects: {subjects:?}" ); // The gateway asked for intermediate tokens for exactly those identities, // with its own identity rather than a sandbox's. @@ -854,9 +864,13 @@ async fn shared_supervisor_acts_as_each_sandboxs_stable_spiffe_identity() { assert_eq!(tokens.exchanges().len(), before_recreate); register(&gateway_id, &recreated_id, &agent_id).await; call_until(&recreated, target.port, &format!("SAW:{recreated_id}")).await; - assert_eq!( - finals(&tokens.exchanges()).last(), - Some(&spiffe_id(&gateway_id, &recreated_id)) + let after_recreate = finals(&tokens.exchanges()[before_recreate..]); + assert!( + !after_recreate.is_empty() + && after_recreate + .iter() + .all(|subject| *subject == spiffe_id(&gateway_id, &recreated_id)), + "final exchange subjects after recreate: {after_recreate:?}" ); assert_sandbox_holds_no_spiffe_material(&recreated).await; diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index fd3746a5bd..bee5b79011 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -842,6 +842,12 @@ With `gatewayConfig.openshell.gateway.spiffe_delegation`, the gateway brokers provider tokens for shared-supervisor sandboxes using the SPIRE admin socket. Check that config table and the gateway's `spire-agent-admin` mount; the shared supervisor receives final access tokens and does not mount the SPIRE socket. +That mount is a `hostPath` volume of type `Directory`: the namespace needs a Pod +Security exception or custom admission policy, the SPIRE agent must create the +directory first, and on OpenShift the SCC must admit `admin_socket_group`. The +gateway asks the agent on its own node, so sandbox registrations must cover +every agent that may run a gateway Pod. `helm status ` prints these +prerequisites when delegation is enabled. If `gatewayConfig.openshell.drivers.kubernetes.provider_spiffe_workload_api_socket_path` is set, the gateway should still render `[openshell.gateway.gateway_jwt]` and @@ -1288,6 +1294,10 @@ credential failures. | OpenShift gateway pod fails to start with an SCC/`runAsUser` error (e.g. `unable to validate against any security context constraint`) | Chart's default `podSecurityContext`/`securityContext` hardcodes `runAsUser`/`fsGroup`, which the restricted-v2 SCC rejects; it must instead inject the namespace-assigned UID/GID range | `oc -n openshell describe pod `; deploy with `podSecurityContext: null` and clear `securityContext.runAsUser` (see `deploy/helm/openshell/ci/values-openshift-scc.yaml`) | | OpenShift `-supervisor` Deployment never creates pods after setting `supervisor_topology = "gateway"` (SCC `runAsUser`/`fsGroup` not in the namespace range) | `supervisor.podSecurityContext` defaults `runAsUser`, `runAsGroup`, and `fsGroup` to 65534, outside the namespace's assigned UID/GID ranges | `kubectl -n openshell get events --field-selector reason=FailedCreate`; compare with the `openshift.io/sa.scc.*` annotations from `oc describe ns openshell`; set `supervisor.podSecurityContext.runAsUser`, `.runAsGroup`, and `.fsGroup` to `null`, as `deploy/helm/openshell/ci/values-openshift-scc.yaml` does, and keep `runAsNonRoot` and `seccompProfile` | | Shared sandboxes stay `Starting` or report `unavailable` readiness with `supervisor_topology = "gateway"`, gateway logs say no shared supervisor host is connected | The `-supervisor` Pod cannot reach or authenticate to the gateway: wrong `OPENSHELL_TLS_CA`, the `-supervisor-token` Secret is missing or still empty (gateway logs `wrote the supervisor token Secret` once it has delivered a token; `no usable supervisor token` in the supervisor means it has not), or the gateway was deployed without TLS or without the sandbox-JWT signing key | `kubectl -n openshell logs deployment/-supervisor` for `gateway accepted the supervisor host` or a refusal; gateway logs for `supervisor host attached`, `wrote the supervisor token Secret`, or `failed to write the supervisor token Secret`; `kubectl -n openshell get secret -supervisor-token -o jsonpath='{.metadata.annotations}'` shows the token expiry; the gateway refuses to start the topology without TLS or `gateway_jwt` | +| Gateway pod rejected with `violates PodSecurity` (`hostPath volumes`) or an SCC error naming `supplementalGroups` after enabling `spiffe_delegation` | The SPIRE admin socket mount is a `hostPath` volume, which Baseline and Restricted forbid; OpenShift restricted-v2 also rejects an `admin_socket_group` outside the namespace range | `kubectl -n openshell get events --field-selector reason=FailedCreate`; add a Pod Security exception or custom admission policy (or custom SCC) for the gateway; do not remove the gateway's security context | +| Gateway pod stays `ContainerCreating` with `hostPath type check failed` for the SPIRE admin directory | The SPIRE agent on that node has not created its admin socket directory | `kubectl -n openshell describe pod `; start the agent with its admin socket on the host before the gateway | +| Shared-sandbox grants fail with `code=unavailable stage=workload_identity` | The gateway cannot open the admin socket: the agent is down, or the gateway lacks the socket's group (the gateway never changes its own groups) | Gateway logs for `dynamic token grant failed`; check the agent and the gateway Pod's `supplementalGroups`, or the process groups outside Helm | +| Shared-sandbox grants fail with `code=workload_identity_rejected stage=workload_identity` after the gateway moves to another node | The new node's SPIRE agent has no registration for the sandbox identities | `kubectl -n openshell get pod -o wide`; register sandboxes for every agent that may run a gateway Pod, for example with a node alias; capping replicas does not cover rescheduling | | OpenShift sandbox pod fails to start (`unable to validate against any security context constraint`) | An explicit sandbox UID/GID override or custom security context conflicts with the namespace's SCC range | Inspect pod events and the namespace's `openshift.io/sa.scc.uid-range` and `openshift.io/sa.scc.supplemental-groups` annotations; remove incompatible `sandbox_uid` / `sandbox_gid` overrides so the driver uses the namespace-assigned identity. Current sandbox pods use `restricted-v2` without a privileged SCC grant; see the [OpenShift guide](https://docs.nvidia.com/openshell/latest/kubernetes/openshift) and [RFC 0012](https://github.com/NVIDIA/OpenShell/blob/main/rfc/0012-isolation-backend/README.md#motivation). | | OpenShift self-hosted Vault/OpenBao credential store pod never schedules (waits time out with `no matching resources found`) | The store's Helm chart pins `runAsUser`/`fsGroup`/seccomp, which restricted-v2 rejects, so the StatefulSet controller never creates the pod | Deploy the store's chart in its OpenShift mode (`--set global.openshift=true` for the OpenBao/Vault chart) so the namespace SCC assigns a compliant security context — no manual SCC grant needed | | Vault credential driver returns HTTP 403 / `Vault Kubernetes auth denied the configured role` on provider create | Vault's `auth/kubernetes` method or the gateway login role is not provisioned, or the role is not bound to the gateway service account and namespace | In Vault: `bao auth enable kubernetes` and `bao write auth/kubernetes/config kubernetes_host=... kubernetes_ca_cert=@...`; ensure the login role's `bound_service_account_names`/`bound_service_account_namespaces` match the gateway SA and namespace and its policy grants the credential paths | From de6dd89ab176c4cc99a5215e2c1abd8e0e7dac80 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 23:22:35 -0700 Subject: [PATCH 6/8] refactor(identity): extract SPIRE sandbox identity adapter Signed-off-by: Drew Newberry --- .agents/skills/helm-dev-environment/SKILL.md | 5 +- AGENTS.md | 1 + Cargo.lock | 16 + crates/openshell-core/src/spiffe.rs | 57 +++ crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/README.md | 11 +- crates/openshell-server/src/cli.rs | 2 +- .../src/grpc/provider/access_token.rs | 37 +- crates/openshell-server/src/lib.rs | 11 +- .../openshell-server/src/spiffe_delegation.rs | 379 ++---------------- crates/openshell-spire/Cargo.toml | 25 ++ crates/openshell-spire/README.md | 73 ++++ crates/openshell-spire/src/client.rs | 123 ++++++ crates/openshell-spire/src/lib.rs | 206 ++++++++++ crates/openshell-spire/src/wire.rs | 81 ++++ tasks/scripts/test-spire-delegation.sh | 20 +- 16 files changed, 666 insertions(+), 382 deletions(-) create mode 100644 crates/openshell-spire/Cargo.toml create mode 100644 crates/openshell-spire/README.md create mode 100644 crates/openshell-spire/src/client.rs create mode 100644 crates/openshell-spire/src/lib.rs create mode 100644 crates/openshell-spire/src/wire.rs diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 23f8c800fe..74ec9c3855 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -101,8 +101,9 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \ `openshell.gateway.spiffe_delegation` overlay (`ci/values-supervisor-spiffe.yaml`), then registers sandbox identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation` -checks delegated issuance and identity selection against a local SPIRE server -and agent without a cluster; the Kubernetes test covers the gateway broker. +checks the `openshell-spire` adapter's delegated issuance and identity selection +against a local SPIRE server and agent without a cluster; the Kubernetes test +covers the gateway broker. The fixture requires admission that permits the admin socket `hostPath` and supplemental group; an unlabeled namespace can still inherit cluster-wide diff --git a/AGENTS.md b/AGENTS.md index d1652c2606..bf4d09d156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-gateway/` | Gateway binary composition | Links selected first-party compute drivers into the backend-agnostic server registry | | `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-spire/` | SPIRE adapter | Gateway-owned SPIRE Delegated Identity client that issues and verifies stable sandbox JWT-SVIDs | | `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 | diff --git a/Cargo.lock b/Cargo.lock index 76e7e0d44d..f2e2588c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5031,6 +5031,7 @@ dependencies = [ "openshell-policy", "openshell-prover", "openshell-providers", + "openshell-spire", "openshell-supervisor-control", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", @@ -5082,6 +5083,21 @@ dependencies = [ "x509-parser 0.16.0", ] +[[package]] +name = "openshell-spire" +version = "0.0.0" +dependencies = [ + "async-trait", + "http 1.4.0", + "openshell-core", + "openshell-crypto", + "prost", + "spiffe", + "tokio", + "tonic", + "tonic-prost", +] + [[package]] name = "openshell-supervisor" version = "0.0.0" diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-core/src/spiffe.rs index 6b307587a9..ff9025767a 100644 --- a/crates/openshell-core/src/spiffe.rs +++ b/crates/openshell-core/src/spiffe.rs @@ -3,11 +3,14 @@ //! Shared SPIFFE helpers used by the gateway and sandbox supervisor. +use std::fmt; use std::path::Path; use base64::Engine as _; use serde::Deserialize; +use crate::token_grant_failure::TokenGrantFailure; + /// SPIFFE JWT-SVID claims used by `OpenShell` token exchange flows. #[derive(Debug, Clone, Deserialize)] pub struct SpiffeJwtClaims { @@ -139,6 +142,48 @@ impl SandboxSpiffeIdPrefix { } } +/// A JWT-SVID verified for a sandbox's stable SPIFFE ID and one audience. +#[derive(Clone)] +pub struct SandboxJwtSvid { + /// The stable SPIFFE ID the token was issued and verified for. + pub spiffe_id: String, + /// The signed token. It is a credential and never printed. + pub token: String, + /// The verified `exp` claim, in Unix seconds. + pub expires_at: i64, +} + +impl fmt::Debug for SandboxJwtSvid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SandboxJwtSvid") + .field("spiffe_id", &self.spiffe_id) + .field("token", &"[REDACTED]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Source of verified sandbox JWT-SVIDs for the gateway's provider broker. +/// +/// The gateway authorizes the sandbox session and the provider credential +/// before each request; implementations own issuance and verification only. +#[async_trait::async_trait] +pub trait SandboxIdentityProvider: fmt::Debug + Send + Sync { + /// Obtain a JWT-SVID for `audience` whose identity is exactly the stable + /// SPIFFE ID of `sandbox_id` under `gateway_id`. + /// + /// # Errors + /// + /// Returns a sanitized failure at the workload identity stage, or a + /// configuration failure when the IDs cannot form a stable SPIFFE ID. + async fn fetch_jwt_svid( + &self, + gateway_id: &str, + sandbox_id: &str, + audience: &str, + ) -> Result; +} + /// A non-empty SPIFFE path segment other than `.` and `..`. #[must_use] pub fn is_spiffe_path_segment(segment: &str) -> bool { @@ -258,6 +303,18 @@ mod tests { } } + #[test] + fn sandbox_jwt_svid_debug_redacts_the_token() { + let svid = SandboxJwtSvid { + spiffe_id: "spiffe://example.org/openshell/gateway/gw/sandbox/sb".into(), + token: "secret-token".into(), + expires_at: 1, + }; + let printed = format!("{svid:?}"); + assert!(!printed.contains("secret-token")); + assert!(printed.contains("sandbox/sb")); + } + #[test] fn trust_domain_extracts_domain_from_spiffe_id() { assert_eq!( diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index a48f89b717..4c3bfe7baf 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -24,6 +24,7 @@ openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } +openshell-spire = { path = "../openshell-spire" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-control = { path = "../openshell-supervisor-control" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 097ef0a914..2b1b174a42 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -108,10 +108,13 @@ lifetime. The session is authorized again around each SPIRE and token-service call. A cache hit and a fresh token then pass the same final check: the grant configuration still matches and the session is still authorized. A fresh token is cached only after that check. Startup validates -the delegation settings, and gateway state construction parses them once and -returns a configuration error when they are invalid; each cache miss fetches -a new SVID and the agent's current JWT bundle. SPIRE still trusts the gateway as a -delegate for its agent's available registration scope. +the delegation settings, and gateway state construction builds the +[`openshell-spire`](../openshell-spire/README.md) adapter once and returns a +configuration error when they are invalid. The broker depends only on +`openshell_core::spiffe::SandboxIdentityProvider`, which returns a JWT-SVID +verified for the requested gateway, sandbox, and audience; each cache miss +fetches a new SVID and the agent's current JWT bundle. SPIRE still trusts the +gateway as a delegate for its agent's available registration scope. `openshell_core::token_grant_failure` defines the time limits: SPIRE and Workload API calls, token-service requests, the gateway's deadline for one diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index c652f85957..fda4061f6f 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -654,7 +654,7 @@ fn prepare_server_config_with_drivers( .and_then(|file| file.openshell.gateway.spiffe_delegation.clone()); // Reject invalid delegation before startup; gateway state parses it later. - crate::spiffe_delegation::SpiffeDelegation::validate_config(&config) + crate::spiffe_delegation::validate_config(&config) .map_err(|error| miette::miette!("{error}"))?; Ok(ServerStartupConfig { diff --git a/crates/openshell-server/src/grpc/provider/access_token.rs b/crates/openshell-server/src/grpc/provider/access_token.rs index 8e75774d12..96c66eae62 100644 --- a/crates/openshell-server/src/grpc/provider/access_token.rs +++ b/crates/openshell-server/src/grpc/provider/access_token.rs @@ -19,6 +19,7 @@ use openshell_core::proto::{ GetSandboxProviderAccessTokenRequest, GetSandboxProviderAccessTokenResponse, ProviderCredentialTokenGrant, }; +use openshell_core::spiffe::SandboxIdentityProvider; use openshell_core::token_grant_failure::BROKERED_TOKEN_GATEWAY_DEADLINE; use std::time::Duration; @@ -223,16 +224,21 @@ async fn obtain( if principal.sandbox_id != session.sandbox_id.as_str() { return Err(token_grant_identity_rejected(tonic::Code::PermissionDenied)); } - let delegation = state - .spiffe_delegation - .as_ref() - .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; + // Startup enables an identity provider only with gateway session JWTs. + let (Some(identity), Some(gateway_jwt)) = ( + state.sandbox_identity.as_deref(), + state.config.gateway_jwt.as_ref(), + ) else { + return Err(token_grant_configuration_status( + tonic::Code::FailedPrecondition, + )); + }; if state.compute.configured_driver_name() != "kubernetes" { return Err(token_grant_configuration_status( tonic::Code::FailedPrecondition, )); } - let gateway_id = delegation.gateway_id(); + let gateway_id = gateway_jwt.gateway_id.as_str(); let key = &request.get_ref().credential_key; let configured = grant_configuration(state, session.sandbox_id.as_str(), key).await?; let grant = &configured.grant; @@ -297,7 +303,8 @@ async fn obtain( state, &bearer, &session, - delegation, + identity, + gateway_id, grant, subject.as_ref(), ttl_override, @@ -347,7 +354,8 @@ async fn acquire( state: &ServerState, bearer: &str, session: &AuthenticatedSandboxSession, - delegation: &crate::spiffe_delegation::SpiffeDelegation, + identity: &dyn SandboxIdentityProvider, + gateway_id: &str, grant: &ProviderCredentialTokenGrant, subject: Option<&Subject>, ttl_override: Option, @@ -355,8 +363,8 @@ async fn acquire( ) -> Result<(oauth::OAuthTokenResponse, i64), Status> { authorize(state, bearer).await?; let audience = effective_jwt_svid_audience(&grant.token_endpoint, &grant.jwt_svid_audience); - let (svid, svid_expiry) = delegation - .fetch_jwt_svid(session.sandbox_id.as_str(), &audience) + let svid = identity + .fetch_jwt_svid(gateway_id, session.sandbox_id.as_str(), &audience) .await .map_err(TokenGrantFailure::to_status)?; authorize(state, bearer).await?; @@ -364,9 +372,6 @@ async fn acquire( let subject_expiry = subject.and_then(|subject| subject.expires_at_ms); let token = if let Some(subject) = subject { let component_svid = fetch_gateway_jwt_svid(&audience).await?; - let stable_id = delegation - .sandbox_spiffe_id(session.sandbox_id.as_str()) - .map_err(|_| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; let intermediate = perform_intermediate_token_exchange( &grant.token_endpoint, &TokenExchangeParams { @@ -374,7 +379,7 @@ async fn acquire( client_assertion_type: &grant.client_assertion_type, subject_token: &subject.value, subject_token_type: &subject.token_type, - audience: &stable_id, + audience: &svid.spiffe_id, scopes: &[], requested_token_type: &grant.requested_token_type, }, @@ -386,7 +391,7 @@ async fn acquire( client, &grant.token_endpoint, &TokenExchangeParams { - client_assertion: &svid, + client_assertion: &svid.token, client_assertion_type: &grant.client_assertion_type, subject_token: &intermediate.access_token, subject_token_type: oauth::ACCESS_TOKEN_TYPE, @@ -403,7 +408,7 @@ async fn acquire( client, &grant.token_endpoint, &oauth::TokenGrantParams { - client_assertion: &svid, + client_assertion: &svid.token, client_assertion_type: &grant.client_assertion_type, audience: &grant.audience, scopes: &grant.scopes, @@ -417,7 +422,7 @@ async fn acquire( let identity_expires_at_ms = session .expires_at .saturating_mul(1000) - .min(svid_expiry.saturating_mul(1000)) + .min(svid.expires_at.saturating_mul(1000)) .min(subject_expiry.unwrap_or(i64::MAX)); let serve_until_ms = serve_until_ms(&token, ttl_override, obtained_at_ms, identity_expires_at_ms).ok_or_else( diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 79d45b8dcd..b87251e5bc 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -415,8 +415,9 @@ pub struct ServerState { /// on demand when the user source is configured. pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, - /// Parsed gateway-owned SPIRE delegation settings. `None` when disabled. - pub(crate) spiffe_delegation: Option, + /// Source of sandbox JWT-SVIDs for brokered provider tokens, built once + /// from the SPIRE delegation settings. `None` when disabled. + pub(crate) sandbox_identity: Option>, /// OIDC admin role name for workspace-level authorization. /// Empty when OIDC is not configured — `authorize_workspace()` treats @@ -493,8 +494,8 @@ impl ServerState { oidc_cache: Option>, credentials: credentials::CredentialRuntime, ) -> Result { - let spiffe_delegation = - spiffe_delegation::SpiffeDelegation::from_config(&config).map_err(Error::config)?; + let sandbox_identity = + spiffe_delegation::sandbox_identity_provider(&config).map_err(Error::config)?; let replica_id = compute::lease::replica_id(); compute.configure_ssh_identities(credentials.clone()); let peer_endpoint = derive_peer_endpoint(&config); @@ -540,7 +541,7 @@ impl ServerState { gateway_interceptors: None, provider_profile_sources: provider_profile_sources::ProviderProfileSources::with_default_sources(), - spiffe_delegation, + sandbox_identity, admin_role, }) } diff --git a/crates/openshell-server/src/spiffe_delegation.rs b/crates/openshell-server/src/spiffe_delegation.rs index 34510830a9..ed13f5ad6b 100644 --- a/crates/openshell-server/src/spiffe_delegation.rs +++ b/crates/openshell-server/src/spiffe_delegation.rs @@ -1,118 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! SPIRE delegated issuance owned by the gateway, never by a supervisor or sandbox. +//! Startup selection of the gateway's sandbox identity provider. +//! +//! The provider broker depends only on +//! [`SandboxIdentityProvider`]; this module chooses the SPIRE adapter when +//! `[openshell.gateway.spiffe_delegation]` is configured. +use std::sync::Arc; + use openshell_core::config::{Config, SpiffeDelegationConfig}; -use openshell_core::spiffe::SandboxSpiffeIdPrefix; -use openshell_core::token_grant_failure::{ - TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, - WORKLOAD_IDENTITY_CALL_TIMEOUT as AGENT_CALL_TIMEOUT, -}; -use std::collections::HashMap; -use std::path::PathBuf; +use openshell_core::spiffe::SandboxIdentityProvider; -/// Validated, immutable settings of gateway-owned SPIRE delegation. +/// Check the delegation settings `config` enables without building them. +/// +/// # Errors /// -/// Gateway state parses these once when it is constructed. Requests reuse the parsed -/// socket, sandbox SPIFFE ID prefix, trust domain, and gateway identity, but -/// fetch each SVID and the agent's current JWT bundle afresh. -#[derive(Debug)] -pub struct SpiffeDelegation { - admin_socket: PathBuf, - prefix: SandboxSpiffeIdPrefix, - trust_domain: spiffe::TrustDomain, - gateway_id: String, +/// Rejects delegation without gateway session JWTs and a positive session +/// TTL, and settings [`SpiffeDelegationConfig::validate`] rejects. +pub fn validate_config(config: &Config) -> Result<(), String> { + enabled(config).map(drop) } -impl SpiffeDelegation { - /// Check the delegation settings `config` enables without building them. - /// - /// # Errors - /// - /// Rejects delegation without gateway session JWTs and a positive session - /// TTL, and settings [`SpiffeDelegationConfig::validate`] - /// rejects. - pub fn validate_config(config: &Config) -> Result<(), String> { - enabled(config).map(drop) - } - - /// The delegation `config` enables, if any. - /// - /// # Errors - /// - /// Rejects what [`Self::validate_config`] rejects. - pub fn from_config(config: &Config) -> Result, String> { - enabled(config)? - .map(|(delegation, gateway_id)| Self::new(delegation, gateway_id)) - .transpose() - } - - /// Parse `config`, already validated for the gateway `gateway_id`. - fn new(config: &SpiffeDelegationConfig, gateway_id: &str) -> Result { - let prefix = SandboxSpiffeIdPrefix::parse(&config.spiffe_id_prefix)?; - let trust_domain = openshell_core::spiffe::trust_domain(prefix.as_str()) - .and_then(|name| spiffe::TrustDomain::new(name).ok()) - .ok_or("spiffe_delegation prefix has no valid trust domain")?; - Ok(Self { - admin_socket: PathBuf::from(&config.admin_socket_path), - prefix, - trust_domain, - gateway_id: gateway_id.to_string(), - }) - } - - pub fn gateway_id(&self) -> &str { - &self.gateway_id - } - - /// Stable SPIFFE ID of `sandbox_id` under this gateway. - pub fn sandbox_spiffe_id(&self, sandbox_id: &str) -> Result { - self.prefix - .sandbox_spiffe_id(&self.gateway_id, sandbox_id) - .map_err(|_| configuration_failure()) - } - - /// The caller must authorize the current sandbox session before requesting issuance. - pub async fn fetch_jwt_svid( - &self, - sandbox_id: &str, - audience: &str, - ) -> Result<(String, i64), TokenGrantFailure> { - let expected = self.sandbox_spiffe_id(sandbox_id)?; - let mut agent = AdminClient::connect(&self.admin_socket).await?; - let response = agent - .fetch_jwt_svids(wire::FetchJwtSvidsRequest { - audience: vec![audience.to_string()], - selectors: vec![wire::Selector { - r#type: "openshell".into(), - value: format!("sandbox:{}:{sandbox_id}", self.gateway_id), - }], - pid: 0, - }) - .await?; - let [svid] = response.svids.as_slice() else { - return Err(identity_rejected()); - }; - let reported = svid - .id - .as_ref() - .map(|id| format!("spiffe://{}{}", id.trust_domain, id.path)); - if reported.as_deref() != Some(expected.as_str()) { - return Err(identity_rejected()); - } - let bundles = agent.jwt_bundles(&self.trust_domain).await?; - openshell_crypto::install_jwt_provider(); - let verified = spiffe::JwtSvid::parse_and_validate(&svid.token, &bundles, &[audience]) - .map_err(|_| identity_rejected())?; - if verified.spiffe_id().to_string() != expected { - return Err(identity_rejected()); - } - Ok((svid.token.clone(), verified.claims().exp())) - } +/// The sandbox identity provider `config` enables, if any. +/// +/// # Errors +/// +/// Rejects what [`validate_config`] rejects. +pub fn sandbox_identity_provider( + config: &Config, +) -> Result>, String> { + let Some(delegation) = enabled(config)? else { + return Ok(None); + }; + let provider = openshell_spire::SpireDelegatedIdentity::new(delegation)?; + Ok(Some(Arc::new(provider))) } -/// Validated delegation settings and gateway ID, when delegation is enabled. -fn enabled(config: &Config) -> Result, String> { +/// Validated delegation settings, when delegation is enabled. +fn enabled(config: &Config) -> Result, String> { let Some(delegation) = &config.spiffe_delegation else { return Ok(None); }; @@ -124,247 +49,5 @@ fn enabled(config: &Config) -> Result, S if jwt.ttl_secs.is_none() { return Err("spiffe_delegation requires a positive gateway_jwt.ttl_secs".into()); } - Ok(Some((delegation, &jwt.gateway_id))) -} - -fn configuration_failure() -> TokenGrantFailure { - TokenGrantFailure::new( - TokenGrantFailureKind::Configuration, - TokenGrantStage::Configuration, - ) -} - -fn identity_rejected() -> TokenGrantFailure { - TokenGrantFailure::new( - TokenGrantFailureKind::WorkloadIdentityRejected, - TokenGrantStage::WorkloadIdentity, - ) -} - -fn agent_failure(kind: TokenGrantFailureKind) -> TokenGrantFailure { - TokenGrantFailure::new(kind, TokenGrantStage::WorkloadIdentity) -} - -fn status_failure(status: &tonic::Status) -> TokenGrantFailure { - // Classify only by code; the agent's message is not retained. - agent_failure(match status.code() { - tonic::Code::PermissionDenied | tonic::Code::Unauthenticated => { - TokenGrantFailureKind::WorkloadIdentityRejected - } - tonic::Code::DeadlineExceeded => TokenGrantFailureKind::Timeout, - tonic::Code::Unavailable => TokenGrantFailureKind::Unavailable, - tonic::Code::InvalidArgument | tonic::Code::Unimplemented => { - TokenGrantFailureKind::Configuration - } - _ => TokenGrantFailureKind::Unclassified, - }) -} - -/// Client for the SPIRE agent's Delegated Identity API. -struct AdminClient { - grpc: tonic::client::Grpc, -} - -impl AdminClient { - async fn connect(socket: &std::path::Path) -> Result { - // Delegation is authorized by the agent attesting this process over - // a local socket; a TCP endpoint could not attest it. - if !socket.is_absolute() { - return Err(configuration_failure()); - } - let endpoint = - spiffe::transport::endpoint::Endpoint::parse(&format!("unix:{}", socket.display())) - .map_err(|_| configuration_failure())?; - let channel = tokio::time::timeout( - AGENT_CALL_TIMEOUT, - spiffe::transport::connector::connect(&endpoint), - ) - .await - .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? - .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable))?; - Ok(Self { - grpc: tonic::client::Grpc::new(channel), - }) - } - - async fn fetch_jwt_svids( - &mut self, - request: wire::FetchJwtSvidsRequest, - ) -> Result { - tokio::time::timeout(AGENT_CALL_TIMEOUT, async { - self.ready().await?; - self.grpc - .unary( - tonic::Request::new(request), - http::uri::PathAndQuery::from_static(wire::FETCH_JWT_SVIDS), - tonic_prost::ProstCodec::default(), - ) - .await - .map(tonic::Response::into_inner) - .map_err(|status| status_failure(&status)) - }) - .await - .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? - } - - /// The agent's current JWT bundle for `trust_domain` only. - async fn jwt_bundles( - &mut self, - trust_domain: &spiffe::TrustDomain, - ) -> Result { - let response: wire::SubscribeToJwtBundlesResponse = - tokio::time::timeout(AGENT_CALL_TIMEOUT, async { - self.ready().await?; - let mut stream = self - .grpc - .server_streaming( - tonic::Request::new(wire::SubscribeToJwtBundlesRequest {}), - http::uri::PathAndQuery::from_static(wire::SUBSCRIBE_TO_JWT_BUNDLES), - tonic_prost::ProstCodec::default(), - ) - .await - .map_err(|status| status_failure(&status))? - .into_inner(); - stream - .message() - .await - .map_err(|status| status_failure(&status))? - .ok_or_else(|| agent_failure(TokenGrantFailureKind::Unavailable)) - }) - .await - .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))??; - let jwks = response - .bundles - .get(&trust_domain.id_string()) - .ok_or_else(identity_rejected)?; - let bundle = spiffe::JwtBundle::from_jwt_authorities(trust_domain.clone(), jwks) - .map_err(|_| identity_rejected())?; - let mut bundles = spiffe::JwtBundleSet::new(); - bundles.add_bundle(bundle); - Ok(bundles) - } - - async fn ready(&mut self) -> Result<(), TokenGrantFailure> { - self.grpc - .ready() - .await - .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable)) - } -} - -/// Wire messages of the SPIRE agent Delegated Identity API. -/// -/// Transcribed from `proto/spire/api/agent/delegatedidentity/v1/delegatedidentity.proto` -/// and `proto/spire/api/types/{selector,jwtsvid,spiffeid}.proto` of -/// at tag `v1.15.3` (commit -/// `adb81b3b0fb87a881693d1d42eaf8b2fa1223e42`), Copyright The SPIFFE Authors, -/// licensed under the Apache License, Version 2.0. Only the messages for -/// `FetchJWTSVIDs` and `SubscribeToJWTBundles` are reproduced; field numbers -/// and types are unchanged. -mod wire { - use super::HashMap; - - pub(super) const FETCH_JWT_SVIDS: &str = - "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/FetchJWTSVIDs"; - pub(super) const SUBSCRIBE_TO_JWT_BUNDLES: &str = - "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToJWTBundles"; - - /// `spire.api.types.Selector` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct Selector { - #[prost(string, tag = "1")] - pub r#type: String, - #[prost(string, tag = "2")] - pub value: String, - } - - /// `spire.api.types.SPIFFEID` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct SpiffeId { - #[prost(string, tag = "1")] - pub trust_domain: String, - #[prost(string, tag = "2")] - pub path: String, - } - - /// `spire.api.types.JWTSVID` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct JwtSvid { - #[prost(string, tag = "1")] - pub token: String, - #[prost(message, optional, tag = "2")] - pub id: Option, - #[prost(int64, tag = "3")] - pub expires_at: i64, - #[prost(int64, tag = "4")] - pub issued_at: i64, - #[prost(string, tag = "5")] - pub hint: String, - } - - /// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsRequest` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct FetchJwtSvidsRequest { - #[prost(string, repeated, tag = "1")] - pub audience: Vec, - #[prost(message, repeated, tag = "2")] - pub selectors: Vec, - #[prost(int32, tag = "3")] - pub pid: i32, - } - - /// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsResponse` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct FetchJwtSvidsResponse { - #[prost(message, repeated, tag = "1")] - pub svids: Vec, - } - - /// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesRequest` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct SubscribeToJwtBundlesRequest {} - - /// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesResponse` - #[derive(Clone, PartialEq, prost::Message)] - pub(super) struct SubscribeToJwtBundlesResponse { - #[prost(map = "string, bytes", tag = "1")] - pub bundles: HashMap>, - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[tokio::test] - #[ignore = "requires the real SPIRE fixture; run mise run test:spire-delegation"] - async fn gateway_obtains_only_the_expected_sandbox_identity() { - let env = |key| std::env::var(key).expect("SPIRE fixture variable"); - let config = SpiffeDelegationConfig { - admin_socket_path: env("OPENSHELL_TEST_SPIRE_ADMIN_SOCKET"), - spiffe_id_prefix: env("OPENSHELL_TEST_SPIRE_ID_PREFIX"), - admin_socket_group: None, - }; - let gateway = env("OPENSHELL_TEST_SPIRE_GATEWAY_ID"); - config.validate(&gateway).unwrap(); - let delegation = SpiffeDelegation::new(&config, &gateway).unwrap(); - let audience = "https://sts.openshell.test"; - let alpha = env("OPENSHELL_TEST_SPIRE_SANDBOX_ALPHA"); - let beta = env("OPENSHELL_TEST_SPIRE_SANDBOX_BETA"); - let (first, expiry) = delegation.fetch_jwt_svid(&alpha, audience).await.unwrap(); - let (second, _) = delegation.fetch_jwt_svid(&beta, audience).await.unwrap(); - assert_ne!(first, second); - assert!(expiry > 0); - for key in [ - "OPENSHELL_TEST_SPIRE_SANDBOX_UNREGISTERED", - "OPENSHELL_TEST_SPIRE_SANDBOX_MISREGISTERED", - "OPENSHELL_TEST_SPIRE_SANDBOX_AMBIGUOUS", - ] { - assert!( - delegation - .fetch_jwt_svid(&env(key), audience) - .await - .is_err() - ); - } - } + Ok(Some(delegation)) } diff --git a/crates/openshell-spire/Cargo.toml b/crates/openshell-spire/Cargo.toml new file mode 100644 index 0000000000..fe70c7deab --- /dev/null +++ b/crates/openshell-spire/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-spire" +description = "SPIRE Delegated Identity adapter for gateway-owned sandbox JWT-SVIDs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } +openshell-crypto = { workspace = true } +async-trait = "0.1" +http = { workspace = true } +prost = { workspace = true } +spiffe = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel"] } +tonic-prost = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-spire/README.md b/crates/openshell-spire/README.md new file mode 100644 index 0000000000..4d770c455f --- /dev/null +++ b/crates/openshell-spire/README.md @@ -0,0 +1,73 @@ +# openshell-spire + +SPIRE adapter for gateway-owned sandbox identities. The gateway uses it to +obtain JWT-SVIDs for sandboxes under shared supervision. In this flow, only +the gateway reaches the SPIRE agent. + +## Interface + +`SpireDelegatedIdentity` implements +`openshell_core::spiffe::SandboxIdentityProvider`. The gateway builds it once +at startup from `[openshell.gateway.spiffe_delegation]` and keeps it as a +trait object, so the provider-token broker does not depend on SPIRE. The +broker authorizes the sandbox session and provider credential first, then asks +for an identity with the gateway ID, sandbox ID, and token-service audience. +It retains provider configuration, token exchange, caching, and the +authorization checks around each call. + +The result is a `SandboxJwtSvid` holding the stable SPIFFE ID, the token, and +its verified expiry. Its `Debug` output omits the token. + +## Issuance + +The stable identity is +`/gateway//sandbox/`, built by +`openshell_core::spiffe::SandboxSpiffeIdPrefix`, which configuration +validation also uses. Gateway and sandbox IDs must already be SPIFFE path +segments; anything else fails as a configuration error before the agent is +contacted. + +For each request the adapter: + +1. Connects to the agent's admin socket. Only an absolute Unix socket path is + accepted, because the agent authorizes the delegate by attesting the + connecting process. The underlying `spiffe` connector rejects Unix sockets + on other platforms. +2. Calls `FetchJWTSVIDs` with the single selector + `openshell:sandbox::` and the audience. +3. Requires exactly one SVID whose reported ID is the stable identity, so a + missing, misregistered, or ambiguous registration is refused. +4. Reads the agent's current JWT bundle for the prefix's trust domain from + `SubscribeToJWTBundles`, then verifies the token signature, audience, and + expiry through the `spiffe` crate with the JWT provider installed by + `openshell-crypto`, and checks the verified subject again. + +Nothing is cached; every request fetches a new SVID and bundle. + +## Errors and limits + +Failures are `openshell_core::token_grant_failure::TokenGrantFailure` values, +which carry only an allowlisted classification. Agent status messages, +tokens, and bundles are never retained or logged. Agent status codes map to +`WorkloadIdentityRejected`, `Timeout`, `Unavailable`, `Configuration`, or +`Unclassified`. Connecting and each agent call are bounded by +`WORKLOAD_IDENTITY_CALL_TIMEOUT`. + +## Wire messages + +`src/wire.rs` transcribes the Delegated Identity API messages for +`FetchJWTSVIDs` and `SubscribeToJWTBundles` from `spire-api-sdk` `v1.15.3`, +with field numbers and types unchanged, instead of generating the full API. + +## Tests + +Unit tests cover identity and socket checks that fail before contacting an +agent. The ignored live test runs against a real SPIRE server and agent: + +```shell +mise run test:spire-delegation +``` + +The task downloads a pinned, checksum-verified SPIRE release, registers +correct, missing, misregistered, and ambiguous sandbox entries, and requires +Linux. diff --git a/crates/openshell-spire/src/client.rs b/crates/openshell-spire/src/client.rs new file mode 100644 index 0000000000..5524e3107c --- /dev/null +++ b/crates/openshell-spire/src/client.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Client for the SPIRE agent's Delegated Identity API. + +use std::path::Path; + +use openshell_core::token_grant_failure::{ + TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, + WORKLOAD_IDENTITY_CALL_TIMEOUT as AGENT_CALL_TIMEOUT, +}; + +use crate::{configuration_failure, identity_rejected, wire}; + +fn agent_failure(kind: TokenGrantFailureKind) -> TokenGrantFailure { + TokenGrantFailure::new(kind, TokenGrantStage::WorkloadIdentity) +} + +fn status_failure(status: &tonic::Status) -> TokenGrantFailure { + // Classify only by code; the agent's message is not retained. + agent_failure(match status.code() { + tonic::Code::PermissionDenied | tonic::Code::Unauthenticated => { + TokenGrantFailureKind::WorkloadIdentityRejected + } + tonic::Code::DeadlineExceeded => TokenGrantFailureKind::Timeout, + tonic::Code::Unavailable => TokenGrantFailureKind::Unavailable, + tonic::Code::InvalidArgument | tonic::Code::Unimplemented => { + TokenGrantFailureKind::Configuration + } + _ => TokenGrantFailureKind::Unclassified, + }) +} + +pub struct AdminClient { + grpc: tonic::client::Grpc, +} + +impl AdminClient { + pub async fn connect(socket: &Path) -> Result { + // Delegation is authorized by the agent attesting this process over + // a local socket; a TCP endpoint could not attest it. + if !socket.is_absolute() { + return Err(configuration_failure()); + } + let endpoint = + spiffe::transport::endpoint::Endpoint::parse(&format!("unix:{}", socket.display())) + .map_err(|_| configuration_failure())?; + let channel = tokio::time::timeout( + AGENT_CALL_TIMEOUT, + spiffe::transport::connector::connect(&endpoint), + ) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? + .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable))?; + Ok(Self { + grpc: tonic::client::Grpc::new(channel), + }) + } + + pub async fn fetch_jwt_svids( + &mut self, + request: wire::FetchJwtSvidsRequest, + ) -> Result { + tokio::time::timeout(AGENT_CALL_TIMEOUT, async { + self.ready().await?; + self.grpc + .unary( + tonic::Request::new(request), + http::uri::PathAndQuery::from_static(wire::FETCH_JWT_SVIDS), + tonic_prost::ProstCodec::default(), + ) + .await + .map(tonic::Response::into_inner) + .map_err(|status| status_failure(&status)) + }) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))? + } + + /// The agent's current JWT bundle for `trust_domain` only. + pub async fn jwt_bundles( + &mut self, + trust_domain: &spiffe::TrustDomain, + ) -> Result { + let response: wire::SubscribeToJwtBundlesResponse = + tokio::time::timeout(AGENT_CALL_TIMEOUT, async { + self.ready().await?; + let mut stream = self + .grpc + .server_streaming( + tonic::Request::new(wire::SubscribeToJwtBundlesRequest {}), + http::uri::PathAndQuery::from_static(wire::SUBSCRIBE_TO_JWT_BUNDLES), + tonic_prost::ProstCodec::default(), + ) + .await + .map_err(|status| status_failure(&status))? + .into_inner(); + stream + .message() + .await + .map_err(|status| status_failure(&status))? + .ok_or_else(|| agent_failure(TokenGrantFailureKind::Unavailable)) + }) + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Timeout))??; + let jwks = response + .bundles + .get(&trust_domain.id_string()) + .ok_or_else(identity_rejected)?; + let bundle = spiffe::JwtBundle::from_jwt_authorities(trust_domain.clone(), jwks) + .map_err(|_| identity_rejected())?; + let mut bundles = spiffe::JwtBundleSet::new(); + bundles.add_bundle(bundle); + Ok(bundles) + } + + async fn ready(&mut self) -> Result<(), TokenGrantFailure> { + self.grpc + .ready() + .await + .map_err(|_| agent_failure(TokenGrantFailureKind::Unavailable)) + } +} diff --git a/crates/openshell-spire/src/lib.rs b/crates/openshell-spire/src/lib.rs new file mode 100644 index 0000000000..97ff1156f1 --- /dev/null +++ b/crates/openshell-spire/src/lib.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SPIRE delegated issuance owned by the gateway, never by a supervisor or sandbox. +//! +//! [`SpireDelegatedIdentity`] implements +//! [`SandboxIdentityProvider`] with the SPIRE agent's Delegated Identity API. + +mod client; +mod wire; + +use std::path::PathBuf; + +use openshell_core::config::SpiffeDelegationConfig; +use openshell_core::spiffe::{SandboxIdentityProvider, SandboxJwtSvid, SandboxSpiffeIdPrefix}; +use openshell_core::token_grant_failure::{ + TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, +}; + +use client::AdminClient; + +/// Validated, immutable settings of gateway-owned SPIRE delegation. +/// +/// The gateway builds this once at startup. Requests reuse the parsed socket, +/// sandbox SPIFFE ID prefix, and trust domain, but fetch each SVID and the +/// agent's current JWT bundle afresh. +#[derive(Debug)] +pub struct SpireDelegatedIdentity { + admin_socket: PathBuf, + prefix: SandboxSpiffeIdPrefix, + trust_domain: spiffe::TrustDomain, +} + +impl SpireDelegatedIdentity { + /// Parse settings that [`SpiffeDelegationConfig::validate`] accepted. + /// + /// # Errors + /// + /// Rejects an invalid SPIFFE ID prefix or trust domain. + pub fn new(config: &SpiffeDelegationConfig) -> Result { + let prefix = SandboxSpiffeIdPrefix::parse(&config.spiffe_id_prefix)?; + let trust_domain = openshell_core::spiffe::trust_domain(prefix.as_str()) + .and_then(|name| spiffe::TrustDomain::new(name).ok()) + .ok_or("spiffe_delegation prefix has no valid trust domain")?; + Ok(Self { + admin_socket: PathBuf::from(&config.admin_socket_path), + prefix, + trust_domain, + }) + } +} + +#[async_trait::async_trait] +impl SandboxIdentityProvider for SpireDelegatedIdentity { + async fn fetch_jwt_svid( + &self, + gateway_id: &str, + sandbox_id: &str, + audience: &str, + ) -> Result { + let expected = self + .prefix + .sandbox_spiffe_id(gateway_id, sandbox_id) + .map_err(|_| configuration_failure())?; + let mut agent = AdminClient::connect(&self.admin_socket).await?; + let response = agent + .fetch_jwt_svids(wire::FetchJwtSvidsRequest { + audience: vec![audience.to_string()], + selectors: vec![wire::Selector { + r#type: "openshell".into(), + value: format!("sandbox:{gateway_id}:{sandbox_id}"), + }], + pid: 0, + }) + .await?; + let [svid] = response.svids.as_slice() else { + return Err(identity_rejected()); + }; + let reported = svid + .id + .as_ref() + .map(|id| format!("spiffe://{}{}", id.trust_domain, id.path)); + if reported.as_deref() != Some(expected.as_str()) { + return Err(identity_rejected()); + } + let bundles = agent.jwt_bundles(&self.trust_domain).await?; + openshell_crypto::install_jwt_provider(); + let verified = spiffe::JwtSvid::parse_and_validate(&svid.token, &bundles, &[audience]) + .map_err(|_| identity_rejected())?; + if verified.spiffe_id().to_string() != expected { + return Err(identity_rejected()); + } + Ok(SandboxJwtSvid { + spiffe_id: expected, + token: svid.token.clone(), + expires_at: verified.claims().exp(), + }) + } +} + +fn configuration_failure() -> TokenGrantFailure { + TokenGrantFailure::new( + TokenGrantFailureKind::Configuration, + TokenGrantStage::Configuration, + ) +} + +fn identity_rejected() -> TokenGrantFailure { + TokenGrantFailure::new( + TokenGrantFailureKind::WorkloadIdentityRejected, + TokenGrantStage::WorkloadIdentity, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn delegation(admin_socket_path: &str) -> SpireDelegatedIdentity { + SpireDelegatedIdentity::new(&SpiffeDelegationConfig { + admin_socket_path: admin_socket_path.into(), + spiffe_id_prefix: "spiffe://example.org/openshell".into(), + admin_socket_group: None, + }) + .unwrap() + } + + #[tokio::test] + async fn rejects_ids_outside_the_stable_identity_before_contacting_the_agent() { + let absent = delegation("/nonexistent-openshell-spire/admin.sock"); + let audience = "https://sts.example.org"; + for (gateway, sandbox) in [("gw/x", "sb"), ("gw", "sb/x"), ("gw", ".."), ("", "sb")] { + let failure = absent + .fetch_jwt_svid(gateway, sandbox, audience) + .await + .unwrap_err(); + assert_eq!(failure, configuration_failure()); + } + // Valid IDs reach the agent, which is unavailable here. + #[cfg(unix)] + assert_eq!( + absent + .fetch_jwt_svid("gw", "sb", audience) + .await + .unwrap_err(), + TokenGrantFailure::new( + TokenGrantFailureKind::Unavailable, + TokenGrantStage::WorkloadIdentity + ) + ); + // The agent can attest the gateway only over a local socket path. + assert_eq!( + delegation("relative/admin.sock") + .fetch_jwt_svid("gw", "sb", audience) + .await + .unwrap_err(), + configuration_failure() + ); + } + + #[tokio::test] + #[ignore = "requires the real SPIRE fixture; run mise run test:spire-delegation"] + async fn gateway_obtains_only_the_expected_sandbox_identity() { + let env = |key| std::env::var(key).expect("SPIRE fixture variable"); + let config = SpiffeDelegationConfig { + admin_socket_path: env("OPENSHELL_TEST_SPIRE_ADMIN_SOCKET"), + spiffe_id_prefix: env("OPENSHELL_TEST_SPIRE_ID_PREFIX"), + admin_socket_group: None, + }; + let gateway = env("OPENSHELL_TEST_SPIRE_GATEWAY_ID"); + config.validate(&gateway).unwrap(); + let delegation = SpireDelegatedIdentity::new(&config).unwrap(); + let audience = "https://sts.openshell.test"; + let alpha = env("OPENSHELL_TEST_SPIRE_SANDBOX_ALPHA"); + let beta = env("OPENSHELL_TEST_SPIRE_SANDBOX_BETA"); + let first = delegation + .fetch_jwt_svid(&gateway, &alpha, audience) + .await + .unwrap(); + let second = delegation + .fetch_jwt_svid(&gateway, &beta, audience) + .await + .unwrap(); + assert_ne!(first.token, second.token); + assert_eq!( + first.spiffe_id, + format!( + "{}/gateway/{gateway}/sandbox/{alpha}", + config.spiffe_id_prefix + ) + ); + assert!(first.expires_at > 0); + for key in [ + "OPENSHELL_TEST_SPIRE_SANDBOX_UNREGISTERED", + "OPENSHELL_TEST_SPIRE_SANDBOX_MISREGISTERED", + "OPENSHELL_TEST_SPIRE_SANDBOX_AMBIGUOUS", + ] { + assert!( + delegation + .fetch_jwt_svid(&gateway, &env(key), audience) + .await + .is_err() + ); + } + } +} diff --git a/crates/openshell-spire/src/wire.rs b/crates/openshell-spire/src/wire.rs new file mode 100644 index 0000000000..01be11e6eb --- /dev/null +++ b/crates/openshell-spire/src/wire.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Wire messages of the SPIRE agent Delegated Identity API. +//! +//! Transcribed from `proto/spire/api/agent/delegatedidentity/v1/delegatedidentity.proto` +//! and `proto/spire/api/types/{selector,jwtsvid,spiffeid}.proto` of +//! at tag `v1.15.3` (commit +//! `adb81b3b0fb87a881693d1d42eaf8b2fa1223e42`), Copyright The SPIFFE Authors, +//! licensed under the Apache License, Version 2.0. Only the messages for +//! `FetchJWTSVIDs` and `SubscribeToJWTBundles` are reproduced; field numbers +//! and types are unchanged. + +use std::collections::HashMap; + +pub const FETCH_JWT_SVIDS: &str = + "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/FetchJWTSVIDs"; +pub const SUBSCRIBE_TO_JWT_BUNDLES: &str = + "/spire.api.agent.delegatedidentity.v1.DelegatedIdentity/SubscribeToJWTBundles"; + +/// `spire.api.types.Selector` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct Selector { + #[prost(string, tag = "1")] + pub r#type: String, + #[prost(string, tag = "2")] + pub value: String, +} + +/// `spire.api.types.SPIFFEID` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct SpiffeId { + #[prost(string, tag = "1")] + pub trust_domain: String, + #[prost(string, tag = "2")] + pub path: String, +} + +/// `spire.api.types.JWTSVID` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct JwtSvid { + #[prost(string, tag = "1")] + pub token: String, + #[prost(message, optional, tag = "2")] + pub id: Option, + #[prost(int64, tag = "3")] + pub expires_at: i64, + #[prost(int64, tag = "4")] + pub issued_at: i64, + #[prost(string, tag = "5")] + pub hint: String, +} + +/// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsRequest` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct FetchJwtSvidsRequest { + #[prost(string, repeated, tag = "1")] + pub audience: Vec, + #[prost(message, repeated, tag = "2")] + pub selectors: Vec, + #[prost(int32, tag = "3")] + pub pid: i32, +} + +/// `spire.api.agent.delegatedidentity.v1.FetchJWTSVIDsResponse` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct FetchJwtSvidsResponse { + #[prost(message, repeated, tag = "1")] + pub svids: Vec, +} + +/// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesRequest` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct SubscribeToJwtBundlesRequest {} + +/// `spire.api.agent.delegatedidentity.v1.SubscribeToJWTBundlesResponse` +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct SubscribeToJwtBundlesResponse { + #[prost(map = "string, bytes", tag = "1")] + pub bundles: HashMap>, +} diff --git a/tasks/scripts/test-spire-delegation.sh b/tasks/scripts/test-spire-delegation.sh index 3212007f7f..3b6615daab 100755 --- a/tasks/scripts/test-spire-delegation.sh +++ b/tasks/scripts/test-spire-delegation.sh @@ -2,13 +2,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Run the gateway's SPIRE delegated-identity test against a real +# Run the openshell-spire delegated-identity test against a real # SPIRE server and agent. # # Downloads a pinned, checksum-verified SPIRE release, starts a server and an # agent with a private admin socket, authorizes this user's processes as the # delegate, registers the sandbox entries the test expects, and runs the -# ignored `spire_tests` test in openshell-server. +# ignored live test in openshell-spire. set -euo pipefail @@ -153,8 +153,6 @@ sandbox_entry() { entry "${ID_PREFIX}/gateway/${GATEWAY_ID}/sandbox/$2" "openshell:sandbox:${GATEWAY_ID}:$1" } -# The delegate: this user's processes, attested over the admin socket. -entry "${DELEGATE_ID}" "unix:uid:$(id -u)" ALPHA="$(sandbox_id)" BETA="$(sandbox_id)" UNREGISTERED="$(sandbox_id)" @@ -166,6 +164,17 @@ sandbox_entry "${BETA}" "${BETA}" sandbox_entry "${MISREGISTERED}" "${ALPHA}" sandbox_entry "${AMBIGUOUS}" "${AMBIGUOUS}" sandbox_entry "${AMBIGUOUS}" "${AMBIGUOUS}-other" +# The delegate: this user's processes, attested over the admin socket. +# Registered last, so once the agent issues it the sandbox entries have +# synced as well. +entry "${DELEGATE_ID}" "unix:uid:$(id -u)" +for _ in $(seq 1 60); do + if "${BIN}/spire-agent" api fetch jwt -audience openshell-fixture \ + -socketPath "${WORK}/agent/public/api.sock" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done status=0 OPENSHELL_TEST_SPIRE_ADMIN_SOCKET="${ADMIN_SOCKET}" \ @@ -176,8 +185,7 @@ OPENSHELL_TEST_SPIRE_ADMIN_SOCKET="${ADMIN_SOCKET}" \ OPENSHELL_TEST_SPIRE_SANDBOX_UNREGISTERED="${UNREGISTERED}" \ OPENSHELL_TEST_SPIRE_SANDBOX_MISREGISTERED="${MISREGISTERED}" \ OPENSHELL_TEST_SPIRE_SANDBOX_AMBIGUOUS="${AMBIGUOUS}" \ - cargo test -p openshell-server --lib spiffe_delegation::tests \ - -- --ignored --nocapture || status=$? + cargo test -p openshell-spire --lib -- --ignored --nocapture || status=$? if [ "${status}" -ne 0 ]; then echo "--- spire-server log" >&2 tail -n 50 "${WORK}/server.log" >&2 || true From e33dd11559ee8efd130751e451176013edd74e92 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 22 Oct 2026 23:37:24 -0700 Subject: [PATCH 7/8] refactor(identity): move shared SPIFFE types out of core Signed-off-by: Drew Newberry --- AGENTS.md | 1 + Cargo.lock | 15 ++++++++++++ crates/openshell-core/Cargo.toml | 1 + crates/openshell-core/src/config.rs | 2 +- crates/openshell-core/src/lib.rs | 1 - crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/README.md | 3 ++- crates/openshell-server/src/grpc/provider.rs | 10 ++++---- .../src/grpc/provider/access_token.rs | 4 ++-- crates/openshell-server/src/lib.rs | 5 +++- .../openshell-server/src/spiffe_delegation.rs | 5 ++-- crates/openshell-spiffe/Cargo.toml | 21 ++++++++++++++++ crates/openshell-spiffe/README.md | 24 +++++++++++++++++++ .../spiffe.rs => openshell-spiffe/src/lib.rs} | 16 +++++++++---- crates/openshell-spire/Cargo.toml | 1 + crates/openshell-spire/README.md | 6 +++-- crates/openshell-spire/src/lib.rs | 6 +++-- .../openshell-supervisor-network/Cargo.toml | 1 + .../src/token_grant.rs | 2 +- examples/governance-interceptor/Cargo.lock | 12 ++++++++++ examples/isolation-backend-v8/Cargo.lock | 12 ++++++++++ .../Cargo.lock | 12 ++++++++++ tests/testkit/Cargo.lock | 12 ++++++++++ 23 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 crates/openshell-spiffe/Cargo.toml create mode 100644 crates/openshell-spiffe/README.md rename crates/{openshell-core/src/spiffe.rs => openshell-spiffe/src/lib.rs} (95%) diff --git a/AGENTS.md b/AGENTS.md index bf4d09d156..6dbe1f7a35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-gateway/` | Gateway binary composition | Links selected first-party compute drivers into the backend-agnostic server registry | | `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-spiffe/` | SPIFFE identity types | Dependency-light SPIFFE ID mapping, claim parsing, and the sandbox identity provider interface | | `crates/openshell-spire/` | SPIRE adapter | Gateway-owned SPIRE Delegated Identity client that issues and verifies stable sandbox JWT-SVIDs | | `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 | diff --git a/Cargo.lock b/Cargo.lock index f2e2588c8d..efe9922685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,6 +4380,7 @@ dependencies = [ "openshell-extension-core", "openshell-otel", "openshell-policy-schema", + "openshell-spiffe", "prost", "prost-types", "protoc-bin-vendored", @@ -5031,6 +5032,7 @@ dependencies = [ "openshell-policy", "openshell-prover", "openshell-providers", + "openshell-spiffe", "openshell-spire", "openshell-supervisor-control", "openshell-supervisor-middleware", @@ -5083,6 +5085,17 @@ dependencies = [ "x509-parser 0.16.0", ] +[[package]] +name = "openshell-spiffe" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "openshell-spire" version = "0.0.0" @@ -5091,6 +5104,7 @@ dependencies = [ "http 1.4.0", "openshell-core", "openshell-crypto", + "openshell-spiffe", "prost", "spiffe", "tokio", @@ -5228,6 +5242,7 @@ dependencies = [ "openshell-policy", "openshell-policy-schema", "openshell-sandbox-backend", + "openshell-spiffe", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "opentelemetry_sdk", diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index d03aafee99..9bcc2570ad 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -15,6 +15,7 @@ openshell-crypto = { workspace = true, features = ["tls-tonic"] } async-trait = "0.1" openshell-extension-core = { path = "../openshell-extension-core" } openshell-policy-schema = { path = "../openshell-policy-schema" } +openshell-spiffe = { path = "../openshell-spiffe" } 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 3992712eb7..491f9eac42 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -238,7 +238,7 @@ impl SpiffeDelegationConfig { { return Err("spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into()); } - crate::spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)? + openshell_spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)? .sandbox_spiffe_id(gateway_id, "sandbox")?; Ok(()) } diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index aa7613554d..1bf8f2c064 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -60,7 +60,6 @@ pub mod sandbox_session; pub mod secrets; pub mod settings; pub mod shell; -pub mod spiffe; pub mod telemetry; pub mod time; pub mod token_grant_failure; diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 4c3bfe7baf..2aba4ea7d2 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -24,6 +24,7 @@ openshell-otel = { path = "../openshell-otel" } openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } +openshell-spiffe = { path = "../openshell-spiffe" } openshell-spire = { path = "../openshell-spire" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-control = { path = "../openshell-supervisor-control" } diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 2b1b174a42..d664e9166e 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -111,7 +111,8 @@ authorized. A fresh token is cached only after that check. Startup validates the delegation settings, and gateway state construction builds the [`openshell-spire`](../openshell-spire/README.md) adapter once and returns a configuration error when they are invalid. The broker depends only on -`openshell_core::spiffe::SandboxIdentityProvider`, which returns a JWT-SVID +[`openshell-spiffe`](../openshell-spiffe/README.md)'s `SandboxIdentityProvider` +with `TokenGrantFailure` as its error, which returns a JWT-SVID verified for the requested gateway, sandbox, and audience; each cache miss fetches a new SVID and the agent's current JWT bundle. SPIRE still trusts the gateway as a delegate for its agent's available registration scope. diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 930c2cbb50..f3c12722ca 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2557,15 +2557,15 @@ use openshell_core::proto::{ ProviderResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, }; -use openshell_core::spiffe::{ - SpiffeJwtClaims, parse_unverified_jwt_svid_claims, trust_domain as spiffe_trust_domain, - workload_api_endpoint, -}; use openshell_providers::{ CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, normalize_profile_id, strategy_output_env_key, strategy_output_spec, strategy_primary_env_key, validate_profile_set, }; +use openshell_spiffe::{ + SpiffeJwtClaims, parse_unverified_jwt_svid_claims, trust_domain as spiffe_trust_domain, + workload_api_endpoint, +}; use std::sync::{Arc, LazyLock, RwLock}; use tonic::{Request, Response}; @@ -5910,7 +5910,7 @@ mod tests { let claims = SpiffeJwtClaims { iss: SECRET.to_string(), sub: SECRET.to_string(), - aud: openshell_core::spiffe::AudienceClaim::One(SECRET.to_string()), + aud: openshell_spiffe::AudienceClaim::One(SECRET.to_string()), exp: 0, }; let gateway = diff --git a/crates/openshell-server/src/grpc/provider/access_token.rs b/crates/openshell-server/src/grpc/provider/access_token.rs index 96c66eae62..f8b0ab9d98 100644 --- a/crates/openshell-server/src/grpc/provider/access_token.rs +++ b/crates/openshell-server/src/grpc/provider/access_token.rs @@ -19,8 +19,8 @@ use openshell_core::proto::{ GetSandboxProviderAccessTokenRequest, GetSandboxProviderAccessTokenResponse, ProviderCredentialTokenGrant, }; -use openshell_core::spiffe::SandboxIdentityProvider; use openshell_core::token_grant_failure::BROKERED_TOKEN_GATEWAY_DEADLINE; +use openshell_spiffe::SandboxIdentityProvider; use std::time::Duration; static ACCESS_TOKENS: LazyLock = LazyLock::new(IntermediateTokenCache::new); @@ -354,7 +354,7 @@ async fn acquire( state: &ServerState, bearer: &str, session: &AuthenticatedSandboxSession, - identity: &dyn SandboxIdentityProvider, + identity: &dyn SandboxIdentityProvider, gateway_id: &str, grant: &ProviderCredentialTokenGrant, subject: Option<&Subject>, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index b87251e5bc..b2bed11f23 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -62,10 +62,12 @@ mod ws_tunnel; use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::telemetry::TelemetryComputeDriver; +use openshell_core::token_grant_failure::TokenGrantFailure; use openshell_core::{Config, Error, ObjectLabels, Result}; use openshell_extension_core::{ BearerTokenSlot, ExtensionAudience, ExtensionCallerKind, ExtensionKind, MAX_EXTENSION_TOKEN_TTL, }; +use openshell_spiffe::SandboxIdentityProvider; use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::{BTreeMap, HashMap}; use std::io::ErrorKind; @@ -417,7 +419,8 @@ pub struct ServerState { /// Source of sandbox JWT-SVIDs for brokered provider tokens, built once /// from the SPIRE delegation settings. `None` when disabled. - pub(crate) sandbox_identity: Option>, + pub(crate) sandbox_identity: + Option>>, /// OIDC admin role name for workspace-level authorization. /// Empty when OIDC is not configured — `authorize_workspace()` treats diff --git a/crates/openshell-server/src/spiffe_delegation.rs b/crates/openshell-server/src/spiffe_delegation.rs index ed13f5ad6b..7f5be2028d 100644 --- a/crates/openshell-server/src/spiffe_delegation.rs +++ b/crates/openshell-server/src/spiffe_delegation.rs @@ -9,7 +9,8 @@ use std::sync::Arc; use openshell_core::config::{Config, SpiffeDelegationConfig}; -use openshell_core::spiffe::SandboxIdentityProvider; +use openshell_core::token_grant_failure::TokenGrantFailure; +use openshell_spiffe::SandboxIdentityProvider; /// Check the delegation settings `config` enables without building them. /// @@ -28,7 +29,7 @@ pub fn validate_config(config: &Config) -> Result<(), String> { /// Rejects what [`validate_config`] rejects. pub fn sandbox_identity_provider( config: &Config, -) -> Result>, String> { +) -> Result>>, String> { let Some(delegation) = enabled(config)? else { return Ok(None); }; diff --git a/crates/openshell-spiffe/Cargo.toml b/crates/openshell-spiffe/Cargo.toml new file mode 100644 index 0000000000..258e062841 --- /dev/null +++ b/crates/openshell-spiffe/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-spiffe" +description = "SPIFFE identity types, parsing, and sandbox identity provider interface for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +async-trait = "0.1" +base64 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-spiffe/README.md b/crates/openshell-spiffe/README.md new file mode 100644 index 0000000000..37b58a2394 --- /dev/null +++ b/crates/openshell-spiffe/README.md @@ -0,0 +1,24 @@ +# openshell-spiffe + +Dependency-light SPIFFE types shared by the gateway, `openshell-core` +configuration validation, and the supervisor. It has no cryptographic +implementation, SPIRE client, or `openshell-core` dependency, so core can +depend on it without a cycle. + +## Contents + +- `SandboxSpiffeIdPrefix` maps a gateway and sandbox to the stable identity + `/gateway//sandbox/`. Both IDs must already + be SPIFFE path segments; nothing is encoded, so distinct IDs always map to + distinct SPIFFE IDs. `SpiffeDelegationConfig::validate` in `openshell-core` + uses it. +- `SandboxIdentityProvider` is the gateway's interface for obtaining a + `SandboxJwtSvid` verified for that stable identity and one audience. The + error is an associated type: the SPIRE adapter in `openshell-spire` uses the + sanitized `TokenGrantFailure`. `SandboxJwtSvid` omits the token from `Debug` + output. +- `workload_api_endpoint`, `trust_domain`, and `is_spiffe_path_segment` handle + Workload API endpoints and SPIFFE ID parts. +- `parse_unverified_jwt_svid_claims` decodes JWT-SVID claims without checking + the signature. Callers must verify the token separately before trusting + them. diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-spiffe/src/lib.rs similarity index 95% rename from crates/openshell-core/src/spiffe.rs rename to crates/openshell-spiffe/src/lib.rs index ff9025767a..a34a0d1b17 100644 --- a/crates/openshell-core/src/spiffe.rs +++ b/crates/openshell-spiffe/src/lib.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 //! Shared SPIFFE helpers used by the gateway and sandbox supervisor. +//! +//! Protocol types, unverified claim parsing, stable sandbox identity mapping, +//! and the sandbox identity provider interface. Signature verification and +//! SPIRE access belong to the crates that implement the interface. use std::fmt; use std::path::Path; @@ -9,8 +13,6 @@ use std::path::Path; use base64::Engine as _; use serde::Deserialize; -use crate::token_grant_failure::TokenGrantFailure; - /// SPIFFE JWT-SVID claims used by `OpenShell` token exchange flows. #[derive(Debug, Clone, Deserialize)] pub struct SpiffeJwtClaims { @@ -169,19 +171,23 @@ impl fmt::Debug for SandboxJwtSvid { /// before each request; implementations own issuance and verification only. #[async_trait::async_trait] pub trait SandboxIdentityProvider: fmt::Debug + Send + Sync { + /// Failure the implementation reports. It must not carry credentials or + /// upstream diagnostics. + type Error; + /// Obtain a JWT-SVID for `audience` whose identity is exactly the stable /// SPIFFE ID of `sandbox_id` under `gateway_id`. /// /// # Errors /// - /// Returns a sanitized failure at the workload identity stage, or a - /// configuration failure when the IDs cannot form a stable SPIFFE ID. + /// Fails when issuance or verification fails, or when the IDs cannot + /// form a stable SPIFFE ID. async fn fetch_jwt_svid( &self, gateway_id: &str, sandbox_id: &str, audience: &str, - ) -> Result; + ) -> Result; } /// A non-empty SPIFFE path segment other than `.` and `..`. diff --git a/crates/openshell-spire/Cargo.toml b/crates/openshell-spire/Cargo.toml index fe70c7deab..727894aed6 100644 --- a/crates/openshell-spire/Cargo.toml +++ b/crates/openshell-spire/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-spiffe = { path = "../openshell-spiffe" } openshell-crypto = { workspace = true } async-trait = "0.1" http = { workspace = true } diff --git a/crates/openshell-spire/README.md b/crates/openshell-spire/README.md index 4d770c455f..4cf91a7a40 100644 --- a/crates/openshell-spire/README.md +++ b/crates/openshell-spire/README.md @@ -7,7 +7,9 @@ the gateway reaches the SPIRE agent. ## Interface `SpireDelegatedIdentity` implements -`openshell_core::spiffe::SandboxIdentityProvider`. The gateway builds it once +`openshell_spiffe::SandboxIdentityProvider` with +`openshell_core::token_grant_failure::TokenGrantFailure` as its error type. +The gateway builds it once at startup from `[openshell.gateway.spiffe_delegation]` and keeps it as a trait object, so the provider-token broker does not depend on SPIRE. The broker authorizes the sandbox session and provider credential first, then asks @@ -22,7 +24,7 @@ its verified expiry. Its `Debug` output omits the token. The stable identity is `/gateway//sandbox/`, built by -`openshell_core::spiffe::SandboxSpiffeIdPrefix`, which configuration +`openshell_spiffe::SandboxSpiffeIdPrefix`, which configuration validation also uses. Gateway and sandbox IDs must already be SPIFFE path segments; anything else fails as a configuration error before the agent is contacted. diff --git a/crates/openshell-spire/src/lib.rs b/crates/openshell-spire/src/lib.rs index 97ff1156f1..e09c8edfce 100644 --- a/crates/openshell-spire/src/lib.rs +++ b/crates/openshell-spire/src/lib.rs @@ -12,10 +12,10 @@ mod wire; use std::path::PathBuf; use openshell_core::config::SpiffeDelegationConfig; -use openshell_core::spiffe::{SandboxIdentityProvider, SandboxJwtSvid, SandboxSpiffeIdPrefix}; use openshell_core::token_grant_failure::{ TokenGrantFailure, TokenGrantFailureKind, TokenGrantStage, }; +use openshell_spiffe::{SandboxIdentityProvider, SandboxJwtSvid, SandboxSpiffeIdPrefix}; use client::AdminClient; @@ -39,7 +39,7 @@ impl SpireDelegatedIdentity { /// Rejects an invalid SPIFFE ID prefix or trust domain. pub fn new(config: &SpiffeDelegationConfig) -> Result { let prefix = SandboxSpiffeIdPrefix::parse(&config.spiffe_id_prefix)?; - let trust_domain = openshell_core::spiffe::trust_domain(prefix.as_str()) + let trust_domain = openshell_spiffe::trust_domain(prefix.as_str()) .and_then(|name| spiffe::TrustDomain::new(name).ok()) .ok_or("spiffe_delegation prefix has no valid trust domain")?; Ok(Self { @@ -52,6 +52,8 @@ impl SpireDelegatedIdentity { #[async_trait::async_trait] impl SandboxIdentityProvider for SpireDelegatedIdentity { + type Error = TokenGrantFailure; + async fn fetch_jwt_svid( &self, gateway_id: &str, diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 6ac1239573..b422cebc5d 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -20,6 +20,7 @@ openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-policy-schema = { path = "../openshell-policy-schema" } +openshell-spiffe = { path = "../openshell-spiffe" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } async-trait = "0.1" diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index a4c587d3d4..df2a25a559 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -392,7 +392,7 @@ async fn fetch_jwt_svid_from_socket( socket_path: &std::path::Path, audience: &str, ) -> Result { - let endpoint = openshell_core::spiffe::workload_api_endpoint(socket_path); + let endpoint = openshell_spiffe::workload_api_endpoint(socket_path); // Connect to SPIRE agent let client = WorkloadApiClient::connect_to(&endpoint) diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index bb73247bb3..9ee3f30674 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1865,6 +1865,7 @@ dependencies = [ "openshell-crypto", "openshell-extension-core", "openshell-policy-schema", + "openshell-spiffe", "prost", "prost-types", "protoc-bin-vendored", @@ -1974,6 +1975,17 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-spiffe" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index a0bdcf1650..c3d63b19f4 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2134,6 +2134,7 @@ dependencies = [ "openshell-crypto", "openshell-extension-core", "openshell-policy-schema", + "openshell-spiffe", "prost", "prost-types", "protoc-bin-vendored", @@ -2240,6 +2241,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-spiffe" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index 1e7d1a030a..ab2a1a298b 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -2019,6 +2019,7 @@ dependencies = [ "openshell-crypto", "openshell-extension-core", "openshell-policy-schema", + "openshell-spiffe", "prost", "prost-types", "protoc-bin-vendored", @@ -2094,6 +2095,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "openshell-spiffe" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "openshell-supervisor-middleware-content-guard" version = "0.0.0" diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 9375647f18..64957d7501 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1893,6 +1893,7 @@ dependencies = [ "openshell-crypto", "openshell-extension-core", "openshell-policy-schema", + "openshell-spiffe", "prost", "prost-types", "protoc-bin-vendored", @@ -1981,6 +1982,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "openshell-spiffe" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "openshell-testkit" version = "0.0.0" From 16f61f5abd603ba484a263f99374ef163db591dd Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 23 Oct 2026 08:19:39 -0700 Subject: [PATCH 8/8] fix(supervisor): follow the sandbox reconciler leader (#4853) --- .agents/skills/helm-dev-environment/SKILL.md | 11 +- crates/openshell-server/src/compute/lease.rs | 98 ++- crates/openshell-server/src/compute/mod.rs | 127 +++- crates/openshell-server/src/lib.rs | 64 +- .../openshell-supervisor-control/Cargo.toml | 3 + crates/openshell-supervisor-control/README.md | 80 ++- .../src/controller.rs | 658 ++++++++++++++++-- .../src/coordinator.rs | 95 ++- .../openshell-supervisor-control/src/lib.rs | 6 +- crates/openshell-supervisor/README.md | 3 +- .../openshell-supervisor/src/shared_host.rs | 96 ++- skills/debug-openshell-cluster/SKILL.md | 17 + 12 files changed, 1133 insertions(+), 125 deletions(-) diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 74ec9c3855..a3392844f4 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -71,7 +71,7 @@ gatewayConfig: The gateway chart creates a single-replica shared supervisor Deployment, Service, NetworkPolicy, and an empty `-supervisor-token` Secret in the release namespace, regardless of shared/managed/operator workspace mode. -The gateway mints a supervisor token from its sandbox-JWT signing key, writes +The gateway publisher mints a supervisor token from its sandbox-JWT signing key, writes it into that Secret (the gateway Role may `get`/`patch` only that Secret), and renews it at half its lifetime; the supervisor mounts the Secret and presents the token on the private `openshell.supervisor.host.v1` assignment stream, @@ -96,6 +96,15 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \ mise run e2e:kubernetes:shared-supervisor ``` +With replicated gateway storage, the existing sandbox reconciler lease holder +also owns the shared supervisor assignment stream and token renewal. Standbys +refuse `Attach` with `UNAVAILABLE`; the supervisor retries through a fresh +connection, so its configured gateway endpoint must reach all eligible replicas +without sticky routing to a standby. A new leader recovers durable assignments +before publishing. Instances keep running during reconnection. Sandbox creates +through a standby may wait for driver reconciliation and the leader observations +carried by the next lease renewal; expired observations mean unknown supervision. + `mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts (`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the `openshell.gateway.spiffe_delegation` overlay diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index fbf039ec81..999f55556b 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -9,14 +9,20 @@ //! //! The lease payload is a small JSON blob — no protobuf definition needed. //! CAS via `put_if` / `delete_if` provides cross-replica safety; the lease -//! is an optimization to reduce contention, not a correctness mechanism. +//! also fences the shared supervisor's assignment publisher. Each holder +//! stops publishing at a conservative local deadline, including when storage +//! is unavailable. Credential-free supervisor observations ride the same CAS +//! so foreground requests on standby replicas can read the leader's view. use crate::persistence::{PersistenceError, Store, WriteCondition}; use openshell_core::time::now_ms; +use openshell_supervisor_control::SharedObservation; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use thiserror::Error; +use tokio::time::Instant; const LEASE_OBJECT_TYPE: &str = "reconciler_lease"; const LEASE_SINGLETON_ID: &str = "singleton"; @@ -42,6 +48,8 @@ pub enum LeaseError { struct LeasePayload { holder: String, acquired_at_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + supervisor_observations: Option>, } #[derive(Debug, Clone)] @@ -51,15 +59,23 @@ pub struct LeaseRecord { pub acquired_at_ms: i64, pub resource_version: u64, pub updated_at_ms: i64, + pub supervisor_observations: Option>, } #[derive(Debug)] pub struct LeaseGuard { resource_version: u64, acquired_at_ms: i64, + /// Conservative local bound: measured before the CAS, so delayed storage + /// responses never extend publication beyond this lease's lifetime. + deadline: Instant, } impl LeaseGuard { + pub fn deadline(&self) -> Instant { + self.deadline + } + #[allow(dead_code)] pub fn resource_version(&self) -> u64 { self.resource_version @@ -87,10 +103,12 @@ impl ReconcilerLease { /// Attempt to create the lease record. Succeeds only if no lease exists. pub async fn try_acquire(&self) -> Result { + let deadline = Instant::now() + self.ttl; let acquired_at_ms = now_ms(); let payload = LeasePayload { holder: self.replica_id.clone(), acquired_at_ms, + supervisor_observations: None, }; let payload_bytes = serde_json::to_vec(&payload).map_err(|e| PersistenceError::Encode(e.to_string()))?; @@ -111,6 +129,7 @@ impl ReconcilerLease { Ok(result) => Ok(LeaseGuard { resource_version: result.resource_version, acquired_at_ms, + deadline, }), Err(PersistenceError::UniqueViolation { .. }) => Err(LeaseError::AlreadyHeld), Err(e) => Err(LeaseError::Store(e)), @@ -127,10 +146,12 @@ impl ReconcilerLease { return Err(LeaseError::AlreadyHeld); } + let deadline = Instant::now() + self.ttl; let acquired_at_ms = now_ms(); let payload = LeasePayload { holder: self.replica_id.clone(), acquired_at_ms, + supervisor_observations: None, }; let payload_bytes = serde_json::to_vec(&payload).map_err(|e| PersistenceError::Encode(e.to_string()))?; @@ -151,6 +172,7 @@ impl ReconcilerLease { Ok(result) => Ok(LeaseGuard { resource_version: result.resource_version, acquired_at_ms, + deadline, }), Err(PersistenceError::Conflict { .. }) => Err(LeaseError::Conflict), Err(e) => Err(LeaseError::Store(e)), @@ -170,9 +192,21 @@ impl ReconcilerLease { /// Renew the lease by CAS-writing the same payload to bump /// `updated_at_ms` and `resource_version`. pub async fn renew(&self, guard: &mut LeaseGuard) -> Result<(), LeaseError> { + self.renew_with_observations(guard, None).await + } + + /// Publish credential-free supervisor observations with the same CAS that + /// renews ownership. A former owner cannot overwrite a new owner's view. + pub async fn renew_with_observations( + &self, + guard: &mut LeaseGuard, + supervisor_observations: Option>, + ) -> Result<(), LeaseError> { + let deadline = Instant::now() + self.ttl; let payload = LeasePayload { holder: self.replica_id.clone(), acquired_at_ms: guard.acquired_at_ms, + supervisor_observations, }; let payload_bytes = serde_json::to_vec(&payload).map_err(|e| PersistenceError::Encode(e.to_string()))?; @@ -192,6 +226,7 @@ impl ReconcilerLease { { Ok(result) => { guard.resource_version = result.resource_version; + guard.deadline = deadline; Ok(()) } Err(PersistenceError::Conflict { .. }) => Err(LeaseError::Conflict), @@ -236,10 +271,20 @@ impl ReconcilerLease { acquired_at_ms: payload.acquired_at_ms, resource_version: record.resource_version, updated_at_ms: record.updated_at_ms, + supervisor_observations: payload.supervisor_observations, })) } } +/// Remaining freshness of a lease observation. Future timestamps never grant +/// more than one TTL; readers also bound the cached view with a local timer. +pub fn remaining_ttl(updated_at_ms: i64, ttl: Duration) -> Duration { + let age_ms = now_ms().saturating_sub(updated_at_ms).max(0); + ttl.saturating_sub(Duration::from_millis( + u64::try_from(age_ms).unwrap_or(u64::MAX), + )) +} + /// Derive a stable replica identity for lease ownership. /// /// Managed workloads commonly receive a stable runtime identity through @@ -291,6 +336,57 @@ mod tests { assert_eq!(record.holder, "replica-1"); } + #[tokio::test] + async fn supervisor_observations_share_lease_cas_and_clear_on_takeover() { + let store = test_store().await; + // A zero TTL lets the test deterministically steal without wall-clock sleeps. + let first = lease(store.clone(), "replica-1", Duration::ZERO); + let second = lease(store, "replica-2", LEASE_TTL); + let mut guard = first.try_acquire().await.unwrap(); + let observations = BTreeMap::from([("rt-a".to_string(), SharedObservation::Pending)]); + first + .renew_with_observations(&mut guard, Some(observations.clone())) + .await + .unwrap(); + assert_eq!( + first.read().await.unwrap().unwrap().supervisor_observations, + Some(observations.clone()) + ); + // Simulate expiration using the same TTL-zero contender as existing + // lease tests. Acquisition clears every previous owner's observation. + let stealer = lease(second.store.clone(), "replica-2", Duration::ZERO); + let mut replacement = stealer.acquire_or_steal().await.unwrap(); + assert!( + second + .read() + .await + .unwrap() + .unwrap() + .supervisor_observations + .is_none() + ); + assert!(matches!( + first + .renew_with_observations(&mut guard, Some(observations.clone())) + .await, + Err(LeaseError::Conflict) + )); + second + .renew_with_observations(&mut replacement, Some(observations.clone())) + .await + .unwrap(); + assert_eq!( + first.read().await.unwrap().unwrap().supervisor_observations, + Some(observations) + ); + assert!(matches!( + first.release(guard).await, + Err(LeaseError::Conflict) + )); + second.release(replacement).await.unwrap(); + assert!(second.read().await.unwrap().is_none()); + } + #[tokio::test] async fn acquire_fails_when_lease_held() { let store = test_store().await; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 1ec4cfd522..e0e5007035 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -884,6 +884,11 @@ impl ComputeRuntime { mut self, supervisor_host: Option, ) -> Self { + if !self.store.is_single_replica() + && let Some(controller) = &supervisor_host + { + controller.require_leadership(); + } self.supervisor_host = supervisor_host; self } @@ -4184,6 +4189,9 @@ impl ComputeRuntime { match lease.acquire_or_steal().await { Ok(guard) => { info!(replica = %lease.replica_id(), "acquired reconciler lease"); + if let Some(controller) = &self.supervisor_host { + controller.start_publishing(guard.deadline()); + } self.run_as_holder(&lease, guard, &mut shutdown_rx, startup_rx.clone()) .await; } @@ -4193,6 +4201,29 @@ impl ComputeRuntime { error = %e, "reconciler lease acquisition attempt failed" ); + if let Some(controller) = &self.supervisor_host { + // Unknown on every failed/expired read. Never turn stale + // readiness into a terminal absence for a running sandbox. + let observations = match tokio::time::timeout( + LEASE_ACQUIRE_INTERVAL, + lease.read(), + ) + .await + { + Ok(Ok(Some(record))) => { + let remaining = + lease::remaining_ttl(record.updated_at_ms, LEASE_TTL); + record + .supervisor_observations + .filter(|_| !remaining.is_zero()) + .map(|observations| { + (tokio::time::Instant::now() + remaining, observations) + }) + } + _ => None, + }; + controller.mirror_observations(observations); + } tokio::select! { () = tokio::time::sleep(LEASE_ACQUIRE_INTERVAL) => {} _ = shutdown_rx.changed() => { @@ -4250,17 +4281,36 @@ impl ComputeRuntime { loop { tokio::select! { + () = tokio::time::sleep_until(guard.deadline()) => { + warn!("reconciler lease expired locally — releasing holder role"); + break; + } () = tokio::time::sleep(LEASE_RENEWAL_INTERVAL) => { - match lease.renew(&mut guard).await { - Ok(()) => { + let previous_deadline = guard.deadline(); + let renewed = tokio::time::timeout_at(previous_deadline, async { + if let Some(controller) = &self.supervisor_host { + lease.renew_with_observations(&mut guard, controller.shared_observations()).await + } else { + lease.renew(&mut guard).await + } + }).await; + match renewed { + Ok(Ok(())) => { + if tokio::time::Instant::now() >= previous_deadline { + warn!("reconciler renewal completed after local expiry — releasing holder role"); + break; + } + if let Some(controller) = &self.supervisor_host { + controller.renew_publishing(guard.deadline()); + } debug!(replica = %lease.replica_id(), "renewed reconciler lease"); } - Err(e) => { - warn!( - replica = %lease.replica_id(), - error = %e, - "reconciler lease renewal failed — releasing holder role" - ); + Ok(Err(e)) => { + warn!(error = %e, "reconciler lease renewal failed — releasing holder role"); + break; + } + Err(_) => { + warn!("reconciler lease renewal timed out — releasing holder role"); break; } } @@ -4268,6 +4318,9 @@ impl ComputeRuntime { _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { info!(replica = %lease.replica_id(), "shutdown — releasing reconciler lease"); + if let Some(controller) = &self.supervisor_host { + controller.stop_publishing(); + } if let Err(e) = lease.release(guard).await { warn!(error = %e, "failed to release reconciler lease on shutdown"); } @@ -4282,6 +4335,9 @@ impl ComputeRuntime { } } + if let Some(controller) = &self.supervisor_host { + controller.stop_publishing(); + } let _ = cancel_tx.send(true); let _ = watch_handle.await; let _ = reconcile_handle.await; @@ -8997,6 +9053,61 @@ mod tests { } } + #[tokio::test] + async fn shared_supervisor_follows_reconciler_leadership_between_replicas() { + use openshell_supervisor_control::SupervisorHostController; + let first_driver = ControlledDriver::new(); + let second_driver = ControlledDriver::new(); + let mut first = test_runtime(first_driver.clone()).await; + let mut second = test_runtime(second_driver.clone()).await; + first.replica_id = "first".to_string(); + second.replica_id = "second".to_string(); + second.store = first.store.clone(); + let first_controller = SupervisorHostController::new(); + let second_controller = SupervisorHostController::new(); + first_controller.require_leadership(); + second_controller.require_leadership(); + first.supervisor_host = Some(first_controller.clone()); + second.supervisor_host = Some(second_controller.clone()); + let (first_stop, first_shutdown) = watch::channel(false); + let (second_stop, second_shutdown) = watch::channel(false); + let (_startup, started) = watch::channel(true); + let first_task = + tokio::spawn(Arc::new(first).lease_coordinator(first_shutdown, started.clone())); + tokio::time::timeout( + Duration::from_secs(5), + first_driver.watch_started.notified(), + ) + .await + .unwrap(); + assert!(first_controller.is_publisher()); + let second_task = + tokio::spawn(Arc::new(second).lease_coordinator(second_shutdown, started)); + assert!(!second_controller.is_publisher()); + first_stop.send_replace(true); + tokio::time::timeout(Duration::from_secs(5), first_task) + .await + .unwrap() + .unwrap(); + assert!(!first_controller.is_publisher()); + tokio::time::timeout( + Duration::from_secs(10), + second_driver.watch_started.notified(), + ) + .await + .unwrap(); + assert!(second_controller.is_publisher()); + // Every new term requires durable recovery, including an explicit + // empty result. An uninitialized controller must not stop workloads. + assert!(!second_controller.is_initialized()); + second_stop.send_replace(true); + tokio::time::timeout(Duration::from_secs(5), second_task) + .await + .unwrap() + .unwrap(); + assert!(!second_controller.is_publisher()); + } + #[tokio::test] async fn create_runtime_inputs_reach_driver_without_persisting_effective_policy_or_secrets() { let driver = Arc::new(TestDriver::default()); diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index b2bed11f23..b2c1c0fff9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -255,33 +255,46 @@ fn spawn_supervisor_token_refresh( mut shutdown_rx: watch::Receiver, ) { tokio::spawn(async move { + let mut next_mint = tokio::time::Instant::now(); + let mut credential = controller.credential(); loop { - let delay = match authority.mint_supervisor_token(ttl) { - Ok(minted) => { - let remaining = minted.expires_at.saturating_sub( - i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_secs()), - ) - .unwrap_or(i64::MAX), - ); - controller.publish_credential( - openshell_supervisor_control::SupervisorCredential { - token: minted.token, - expires_at: minted.expires_at, - }, - ); - Duration::from_secs(u64::try_from(remaining).unwrap_or(0) / 2) - .max(Duration::from_secs(1)) - } - Err(error) => { - warn!(%error, "failed to mint the supervisor token; retrying"); - Duration::from_secs(10) - } - }; + if credential.has_changed().unwrap_or(false) && credential.borrow_and_update().is_none() + { + next_mint = tokio::time::Instant::now(); + } + if !controller.is_publisher() { + // Mint promptly when this replica acquires the existing lease; + // standbys never compete to overwrite the supervisor's token. + next_mint = tokio::time::Instant::now(); + } else if tokio::time::Instant::now() >= next_mint { + let delay = match authority.mint_supervisor_token(ttl) { + Ok(minted) => { + let remaining = minted.expires_at.saturating_sub( + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()), + ) + .unwrap_or(i64::MAX), + ); + controller.publish_credential( + openshell_supervisor_control::SupervisorCredential { + token: minted.token, + expires_at: minted.expires_at, + }, + ); + Duration::from_secs(u64::try_from(remaining).unwrap_or(0) / 2) + .max(Duration::from_secs(1)) + } + Err(error) => { + warn!(%error, "failed to mint the supervisor token; retrying"); + Duration::from_secs(10) + } + }; + next_mint = tokio::time::Instant::now() + delay; + } tokio::select! { - () = tokio::time::sleep(delay) => {} + () = tokio::time::sleep(Duration::from_secs(1)) => {} changed = shutdown_rx.changed() => { if changed.is_err() || *shutdown_rx.borrow() { return; @@ -291,6 +304,7 @@ fn spawn_supervisor_token_refresh( } }); } + pub use auth::audit::ConnectionSource; pub use multiplex::{MultiplexService, MultiplexedService}; pub use persistence::Store; diff --git a/crates/openshell-supervisor-control/Cargo.toml b/crates/openshell-supervisor-control/Cargo.toml index b61564c2a5..962609cda6 100644 --- a/crates/openshell-supervisor-control/Cargo.toml +++ b/crates/openshell-supervisor-control/Cargo.toml @@ -23,5 +23,8 @@ tokio-stream = { workspace = true } tonic = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + [lints] workspace = true diff --git a/crates/openshell-supervisor-control/README.md b/crates/openshell-supervisor-control/README.md index 124caa5080..4bdf2e184d 100644 --- a/crates/openshell-supervisor-control/README.md +++ b/crates/openshell-supervisor-control/README.md @@ -23,7 +23,15 @@ re-implementing the control contracts. delayed older hello is refused, and reports from superseded streams, acks of revisions never sent, regressing acks, and oversized reports are dropped. A stream ends when the token that opened it expires; hosted - instances keep running. At most 256 instances are assigned. + instances keep running. At most 256 instances are assigned. Selection + considers only candidates still in the durable runnable records: a + recovered list beyond capacity keeps the candidates that are currently + assigned or that the host reports running, then fills the rest in route + order and leaves the excess unassigned. After a takeover, an oversized + list keeps the previous set until the host's initial report shows what it + runs, so the bounded set never stops a running instance. + After initialization every withdrawal advances the revision, so a + recovery listed before it cannot re-admit the withdrawn route. - `registration`: `SandboxInstanceRegistration` is the JSON launch material one assignment carries and the driver persists with the generation. It grants the supervisor exactly that generation's gateway and Sandbox Protocol @@ -35,17 +43,20 @@ re-implementing the control contracts. - `coordinator`: `SharedSupervision` is what the gateway constructs and hands to the compute driver. `launch` validates the driver's registration, persists the encoded launch material through the driver's operation, and - only then assigns the instance; a host at capacity is refused before the - workload runs. `begin_recovery` captures + only then assigns the instance; the publisher refuses a create beyond + capacity before the workload runs. `begin_recovery` captures the revision before the driver lists its durable records and `complete_recovery` reuses current assignments, reads the rest through the driver's operation, binds each to its recorded route and sandbox, and keeps - the previous set when any read fails; a list that raced a lifecycle edit is - refused and derived again. `supervision` and `wait_for_ready` interpret the - host's observations in one place: a disconnected host is unknown, a host - that has not reconciled to the assignment yet is degraded, and only a host - that reconciled past it without hosting the instance, hosts it for another - sandbox, or reports it ended makes it unavailable. + the previous set when any read fails or material is malformed; a list that + raced a lifecycle edit or the end of the term is refused and derived again. + A listed generation whose material is gone, or that exceeds capacity, is + excluded for that recovery. `supervision` and `wait_for_ready` interpret + the host's observations in one place: a disconnected host is unknown, a + host that has not reconciled to the assignment or exclusion yet is + degraded, and only a host that reconciled past it without hosting the + instance, hosts it for another sandbox, or reports it ended makes it + unavailable. - `credential`: `deliver_credentials` delivers the current supervisor token and every renewal through the driver's operation, retrying a failed delivery until it succeeds or a newer token supersedes it. @@ -61,5 +72,52 @@ its lifetime, and gives the coordination to the driver. The driver back launch material, provisions the workload's path to the supervisor, checks its own infrastructure evidence alongside the coordinator's verdict, and delivers the token into the Secret the supervisor Pod mounts. The supervisor -(`openshell-supervisor`) consumes only the registration and status types; it -owns admission, per-instance runtime, routing, and cleanup. +(`openshell-supervisor`) consumes only the registration and status types and +the private standby refusal marker (`is_standby_refusal`); it owns admission, +per-instance runtime, routing, and cleanup. + +## Gateway leadership + +With replicated gateway storage, the existing sandbox reconciler lease also +owns supervisor assignment publication. A standby refuses `Attach` with +`UNAVAILABLE` and the `x-openshell-supervisor-host: standby` metadata marker; +the supervisor retries after a short jittered delay on a fresh connection to +the gateway endpoint, and keeps its exponential backoff for every other +failure. That endpoint must reach all eligible gateway replicas, without +session affinity pinning the supervisor to a standby. Single-replica storage +keeps a local publisher and does not acquire a database lease. + +Acquiring the lease starts a new term with an uninitialized desired set. The +compute driver's initial watch recovers the complete set from durable records +before any snapshot is sent. A new term invalidates an older recovery pass. +Losing or releasing the lease closes the assignment stream and discards the +term's desired state before worker cleanup, and a conservative monotonic +deadline stops publication even when a renewal cannot finish; an expired or +former publisher edits nothing. Hosted instances keep running while +reconnecting. +Only the publisher mints supervisor tokens; the existing delivery path carries +them to the supervisor. + +Foreground sandbox operations still run on every gateway replica. Their launch +material and lifecycle changes are durable, so the leader's existing driver +reconciliation discovers creates and withdrawals from other replicas; a +standby keeps no assignments of its own. Admission through a standby is +therefore eventual: a create beyond capacity is not refused up front but left +unassigned by the leader's recovery, and its supervision then becomes +unavailable. The +leader includes credential-free, generation-routed instance observations in +the same compare-and-swap write that renews the reconciler lease. Standbys use +that view for readiness; they never publish assignments from it. Missing, +unreadable, or expired observations mean unknown supervision, not an absent +instance. A route the leader has not recovered yet stays pending, even if +its connected host has already acknowledged a snapshot without that route. +Only an acknowledged assignment, or an acknowledged snapshot omitting a route +the leader's latest complete recovery excluded, can establish that an +instance is absent. Exclusions belong to the term and are cleared when the +route is reassigned, withdrawn, or recovered with its material again. +Observations are eventually consistent with lease renewals (10 +seconds) and standby polls (5 seconds); driver reconciliation can add up to its +normal interval before a remote lifecycle change reaches the supervisor. + +This coordinates gateway ownership of one shared supervisor. It does not add +supervisor HA, scheduling, autoscaling, or recovery from supervisor process loss. diff --git a/crates/openshell-supervisor-control/src/controller.rs b/crates/openshell-supervisor-control/src/controller.rs index 2d0aa885d9..b24a33e27b 100644 --- a/crates/openshell-supervisor-control/src/controller.rs +++ b/crates/openshell-supervisor-control/src/controller.rs @@ -20,20 +20,27 @@ //! before the records were listed, so a list that raced a lifecycle edit //! never resurrects a withdrawn instance or discards a new one. //! +//! Only the publisher owns desired state. A replica that does not publish +//! keeps none: its lifecycle edits are durable records that the publisher's +//! next recovery discovers, so admission on a replicated gateway is eventual. +//! //! Exactly one host serves a gateway: a new attach from a newer connection of //! the same host process, or from a new host process, supersedes the current //! session; reports and acks from a superseded stream are dropped, and a //! delayed hello from an older connection of the current process is refused. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, watch}; +use tokio::time::Instant; use tokio_stream::StreamExt as _; use tokio_stream::wrappers::ReceiverStream; use tonic::{Status, Streaming}; +use crate::SandboxInstanceStatusReport; use crate::proto::{ AssignmentSnapshot, HostAccepted, HostGatewayHeartbeat, HostGatewayMessage, HostHello, HostMessage, HostStatus, SandboxInstanceAssignment, SandboxInstanceStatus, @@ -51,10 +58,16 @@ const HELLO_TIMEOUT: Duration = Duration::from_secs(10); const SEND_TIMEOUT: Duration = Duration::from_secs(30); /// Gateway-to-host messages queued before the stream applies backpressure. const OUTBOUND_QUEUE: usize = 16; -/// Most sandbox instances one shared supervisor host runs. The gateway -/// refuses to assign beyond it, and the host refuses snapshots beyond it, so -/// a sandbox is never created that its host could not accept. +/// Most sandbox instances one shared supervisor host runs. The publishing +/// gateway refuses to assign beyond it, recovery leaves any excess +/// unassigned, and the host refuses snapshots beyond it. pub const MAX_ASSIGNED_INSTANCES: usize = 256; +/// Metadata key marking a standby replica's `UNAVAILABLE` refusal of `Attach`. +/// +/// Paired with [`STANDBY_REFUSAL_VALUE`], it lets the host retry leader +/// discovery promptly rather than backing off as it would from an outage. +pub const STANDBY_REFUSAL_METADATA: &str = "x-openshell-supervisor-host"; +pub const STANDBY_REFUSAL_VALUE: &str = "standby"; /// Most instances one host status report may describe: every assignment plus /// instances still being removed. pub const MAX_REPORTED_INSTANCES: usize = 4 * MAX_ASSIGNED_INSTANCES; @@ -88,16 +101,45 @@ pub struct SupervisorCredential { pub enum ObservedInstance { /// No host session is connected. NoHost, - /// A host is connected but has not reconciled to the snapshot that - /// assigned this route. + /// A host is connected, but this gateway has not recovered the route's + /// assignment yet or the host has not reconciled the snapshot assigning + /// or excluding it. Pending, - /// The host reconciled to that snapshot (or the route is not assigned) - /// and does not host the route. + /// The host reconciled to the snapshot assigning this route and does + /// not host it, or to one omitting a route the latest complete recovery + /// left unassigned. Absent, /// The host's latest report of the instance. Reported(SandboxInstanceStatus), } +/// Credential-free observations carried by the gateway's existing reconciler +/// lease. Routes are generation-specific; an omitted route is unknown, never +/// evidence that a newly created instance is absent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SharedObservation { + Pending, + Absent, + Reported(SandboxInstanceStatusReport), +} + +#[derive(Clone, Copy)] +enum Publisher { + Standalone, + Standby, + Lease(Instant), +} + +impl Publisher { + fn active(self) -> bool { + match self { + Self::Standalone => true, + Self::Standby => false, + Self::Lease(deadline) => Instant::now() < deadline, + } + } +} + struct Assigned { assignment: SandboxInstanceAssignment, /// Snapshot revision that first carried this exact assignment. @@ -111,6 +153,10 @@ struct Desired { assignments: BTreeMap, /// Edits made before initialization, applied over the first full list. pending: BTreeMap>, + /// Routes the latest complete recovery listed but left unassigned (no + /// launch material, or beyond capacity), each with the first revision + /// whose snapshot omits it. Assigning or withdrawing a route clears it. + excluded: BTreeMap, } impl Desired { @@ -163,10 +209,14 @@ struct Observed { } struct Inner { + publisher: Mutex, + /// Standbys read only lease-fenced observations, never another replica's + /// desired state or credentials. The local deadline also bounds cached reads. + mirror: Mutex)>>, desired: Mutex, /// Latest supervisor token the gateway minted; `None` until the first. credential: watch::Sender>, - /// Current desired revision; 0 while uninitialized. + /// Monotonic local revision, also bumped at the start of a new term. revision: watch::Sender, observed: Mutex, } @@ -203,6 +253,35 @@ pub enum AssignmentError { /// The change would assign more instances than one host runs. #[error("the shared supervisor hosts at most {MAX_ASSIGNED_INSTANCES} sandbox instances")] Capacity, + /// This replica does not publish assignments, so a full resync begun in + /// an ended term is discarded. + #[error("this gateway replica does not publish shared supervisor assignments")] + Standby, +} + +/// The refusal a replica that does not publish gives an attaching host. +fn standby_refusal() -> Status { + let mut metadata = tonic::metadata::MetadataMap::new(); + metadata.insert( + STANDBY_REFUSAL_METADATA, + tonic::metadata::MetadataValue::from_static(STANDBY_REFUSAL_VALUE), + ); + Status::with_metadata( + tonic::Code::Unavailable, + "reconnect to the gateway reconciler leader", + metadata, + ) +} + +/// Whether `status` is a standby replica's refusal of `Attach`, as opposed to +/// a transport or gateway failure. +#[must_use] +pub fn is_standby_refusal(status: &Status) -> bool { + status.code() == tonic::Code::Unavailable + && status + .metadata() + .get(STANDBY_REFUSAL_METADATA) + .is_some_and(|value| value.to_str().ok() == Some(STANDBY_REFUSAL_VALUE)) } fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -216,6 +295,8 @@ impl SupervisorHostController { pub fn new() -> Self { Self { inner: Arc::new(Inner { + publisher: Mutex::new(Publisher::Standalone), + mirror: Mutex::new(None), desired: Mutex::new(Desired::default()), credential: watch::Sender::new(None), revision: watch::Sender::new(0), @@ -224,9 +305,113 @@ impl SupervisorHostController { } } + /// Require the existing gateway reconciler lease before serving a host. + /// Call before exposing this controller on a replicated gateway. + pub fn require_leadership(&self) { + self.stop_publishing(); + } + + /// Start a new reconciler term. Discard the previous replica-local view: + /// only a fresh complete recovery may initialize this term's assignments. + /// Bumping the revision fences recovery work begun in an earlier term. + pub fn start_publishing(&self, deadline: Instant) { + let mut publisher = lock(&self.inner.publisher); + let mut desired = lock(&self.inner.desired); + let revision = desired.revision + 1; + *desired = Desired { + revision, + ..Desired::default() + }; + *lock(&self.inner.mirror) = None; + Self::disconnect(&mut lock(&self.inner.observed)); + *publisher = Publisher::Lease(deadline); + self.inner.revision.send_replace(revision); + } + + /// Extend publication only after the existing lease was renewed. + pub fn renew_publishing(&self, deadline: Instant) { + let mut publisher = lock(&self.inner.publisher); + if matches!(*publisher, Publisher::Lease(_)) && publisher.active() { + *publisher = Publisher::Lease(deadline); + } + } + + /// Revoke publication before releasing the lease or waiting for worker + /// cleanup. Existing sandbox instances survive their stream closing. + /// The term's desired state is discarded and its revision fenced, so a + /// standby holds none and a recovery begun in the term cannot + /// reinitialize it. + pub fn stop_publishing(&self) { + let mut publisher = lock(&self.inner.publisher); + *publisher = Publisher::Standby; + let mut desired = lock(&self.inner.desired); + let revision = desired.revision + 1; + *desired = Desired { + revision, + ..Desired::default() + }; + Self::disconnect(&mut lock(&self.inner.observed)); + self.inner.credential.send_replace(None); + self.inner.revision.send_replace(revision); + } + + fn disconnect(observed: &mut Observed) { + if let Some(session) = observed.session.take() { + session.superseded.send_replace(true); + } + observed.status = None; + } + + #[must_use] + pub fn is_publisher(&self) -> bool { + lock(&self.inner.publisher).active() + } + + /// Snapshot observations for the next CAS renewal of the reconciler + /// lease. Contains no launch material or bearer tokens. + #[must_use] + pub fn shared_observations(&self) -> Option> { + let publisher = lock(&self.inner.publisher); + if !publisher.active() { + return None; + } + let desired = lock(&self.inner.desired); + let observed = lock(&self.inner.observed); + observed.session.as_ref()?; + Some( + desired + .assignments + .keys() + .chain(desired.excluded.keys()) + .map(|route| { + let value = match Self::observe(&desired, &observed, route) { + ObservedInstance::Reported(status) => SharedObservation::Reported( + SandboxInstanceStatusReport::from_proto(&status), + ), + ObservedInstance::Absent => SharedObservation::Absent, + _ => SharedObservation::Pending, + }; + (route.clone(), value) + }) + .collect(), + ) + } + + /// Replace the standby's view from a fresh read of the existing lease. + /// Failed reads, absent owners, and expired leases clear it immediately. + pub fn mirror_observations( + &self, + observations: Option<(Instant, BTreeMap)>, + ) { + *lock(&self.inner.mirror) = observations; + } + /// Publish a freshly minted supervisor token for delivery to the host. pub fn publish_credential(&self, credential: SupervisorCredential) { - self.inner.credential.send_replace(Some(credential)); + let publisher = lock(&self.inner.publisher); + if publisher.active() { + self.inner.credential.send_replace(Some(credential)); + } } /// The current supervisor token and every renewal after it. @@ -242,8 +427,8 @@ impl SupervisorHostController { lock(&self.inner.desired).initialized } - /// Current desired revision: 0 while uninitialized. Read it before - /// deriving a full list for [`Self::replace_assignments`]; the + /// Current desired revision, including the fence for this leadership + /// term. Read it before deriving a full list for [`Self::replace_assignments`]; the /// coordinator does so through /// [`SharedSupervision::begin_recovery`](crate::SharedSupervision::begin_recovery). #[must_use] @@ -261,20 +446,33 @@ impl SupervisorHostController { } /// Replace the whole desired set with `assignments`, derived from - /// durable records while the revision was `baseline`. + /// durable records while the revision was `baseline`; `excluded` names + /// the listed routes that are deliberately left unassigned. /// /// Initializes the controller; edits made before initialization are - /// applied on top of the list. After initialization the replacement is - /// compare-and-replace: when the revision moved past `baseline` while + /// applied on top of the list. The replacement is compare-and-replace: + /// when the revision moved past `baseline` while /// the list was being derived, nothing changes and the conflict is - /// returned so the caller derives the list again. + /// returned so the caller derives the list again. Only the publisher + /// replaces its set. + /// + /// Only listed candidates are selected. A list beyond the host's + /// capacity keeps those currently assigned or that the host reports + /// running, then fills the rest in route order; the remainder is + /// excluded like `excluded`. Until this term's host has sent its initial + /// report it is refused, since any cut could stop a running instance. pub fn replace_assignments( &self, baseline: u64, assignments: impl IntoIterator, + excluded: impl IntoIterator, ) -> Result { + let publisher = lock(&self.inner.publisher); let mut desired = lock(&self.inner.desired); - if desired.initialized && desired.revision != baseline { + if !publisher.active() { + return Err(AssignmentError::Standby); + } + if desired.revision != baseline { return Err(AssignmentError::Conflict { baseline, current: desired.revision, @@ -296,8 +494,42 @@ impl SupervisorHostController { } } } + let mut excluded = excluded + .into_iter() + .filter(|route| !resolved.contains_key(route) && !desired.pending.contains_key(route)) + .collect::>(); if resolved.len() > MAX_ASSIGNED_INSTANCES { - return Err(AssignmentError::Capacity); + let observed = lock(&self.inner.observed); + let Some(status) = &observed.status else { + return Err(AssignmentError::Capacity); + }; + let running = status + .instances + .iter() + .filter(|instance| { + !SandboxInstanceStatusReport::from_proto(instance) + .phase + .is_terminal() + }) + .map(|instance| instance.route.as_str()) + .collect::>(); + // A stable sort keeps route order within each tier. + let mut ranked = resolved.keys().cloned().collect::>(); + ranked.sort_by_key(|route| { + ( + !desired.assignments.contains_key(route), + !running.contains(route.as_str()), + ) + }); + let overflow = ranked.split_off(MAX_ASSIGNED_INSTANCES); + tracing::warn!( + unassigned = overflow.len(), + "shared sandbox instances exceed the supervisor's capacity; leaving the excess unassigned" + ); + for route in overflow { + resolved.remove(&route); + excluded.insert(route); + } } desired.pending.clear(); let next = desired.revision + 1; @@ -315,14 +547,33 @@ impl SupervisorHostController { desired.revision = next; self.inner.revision.send_replace(next); } - Ok(desired.revision) + // A route that stays excluded keeps the revision that first omitted + // it, so an unchanged recovery needs no new snapshot or ack. + let revision = desired.revision; + let previous = std::mem::take(&mut desired.excluded); + desired.excluded = excluded + .into_iter() + .map(|route| { + let since = previous.get(&route).copied().unwrap_or(revision); + (route, since) + }) + .collect(); + Ok(revision) } /// Assign one instance. Before initialization the edit is kept until the /// first full list arrives. Refused when it would exceed the host's /// capacity, so the caller fails its create before anything runs. + /// + /// A replica that does not publish keeps nothing: the persisted launch + /// material is the assignment, and the publisher's recovery admits it or + /// leaves it unassigned beyond capacity. pub fn assign(&self, assignment: SandboxInstanceAssignment) -> Result<(), AssignmentError> { + let publisher = lock(&self.inner.publisher); let mut desired = lock(&self.inner.desired); + if !publisher.active() { + return Ok(()); + } if !desired.initialized { let pending_additions = desired .pending @@ -344,6 +595,7 @@ impl SupervisorHostController { { return Err(AssignmentError::Capacity); } + desired.excluded.remove(&assignment.route); let next = desired.revision + 1; if desired.put(assignment, next) { desired.revision = next; @@ -352,18 +604,28 @@ impl SupervisorHostController { Ok(()) } - /// Withdraw one instance so the host stops it. + /// Withdraw one instance so the host stops it. A replica that does not + /// publish keeps nothing; the publisher's recovery discovers the stop. + /// + /// After initialization every withdrawal advances the revision, even for + /// a route that is excluded or not yet discovered, so a recovery listed + /// before it conflicts instead of admitting the withdrawn generation. No + /// tombstone is kept: the next recovery lists the durable records again. pub fn unassign(&self, route: &str) { + let publisher = lock(&self.inner.publisher); let mut desired = lock(&self.inner.desired); + if !publisher.active() { + return; + } if !desired.initialized { desired.pending.insert(route.to_string(), None); return; } - if desired.assignments.remove(route).is_some() { - desired.revision += 1; - let revision = desired.revision; - self.inner.revision.send_replace(revision); - } + desired.excluded.remove(route); + desired.assignments.remove(route); + desired.revision += 1; + let revision = desired.revision; + self.inner.revision.send_replace(revision); } /// Whether a host session is currently connected. @@ -375,11 +637,50 @@ impl SupervisorHostController { /// What the connected host reports for `route`. #[must_use] pub fn observed(&self, route: &str) -> ObservedInstance { - let desired = lock(&self.inner.desired); - let observed = lock(&self.inner.observed); + let publisher = lock(&self.inner.publisher); + if !publisher.active() { + let mirror = lock(&self.inner.mirror); + return match mirror + .as_ref() + .filter(|(deadline, _)| Instant::now() < *deadline) + .and_then(|(_, observations)| observations.get(route)) + { + Some(SharedObservation::Reported(status)) => { + ObservedInstance::Reported(status.to_proto()) + } + Some(SharedObservation::Absent) => ObservedInstance::Absent, + Some(SharedObservation::Pending) => ObservedInstance::Pending, + None => ObservedInstance::NoHost, + }; + } + Self::observe( + &lock(&self.inner.desired), + &lock(&self.inner.observed), + route, + ) + } + + fn observe(desired: &Desired, observed: &Observed, route: &str) -> ObservedInstance { if observed.session.is_none() { return ObservedInstance::NoHost; } + let since = match desired + .assignments + .get(route) + .filter(|_| desired.initialized) + { + Some(assigned) => assigned.revision, + // The latest complete recovery listed the route and left it + // unassigned; the host's ack of a snapshot omitting it is a verdict. + None => match desired.excluded.get(route) { + Some(since) => *since, + // A create can have reached another replica before this + // leader's recovery discovers its durable assignment. Never + // suspend that workload based on a snapshot that did not + // assign it. + None => return ObservedInstance::Pending, + }, + }; let Some(status) = &observed.status else { return ObservedInstance::Pending; }; @@ -390,11 +691,10 @@ impl SupervisorHostController { { return ObservedInstance::Reported(instance.clone()); } - match desired.assignments.get(route) { - Some(assigned) if assigned.revision > status.applied_revision => { - ObservedInstance::Pending - } - _ => ObservedInstance::Absent, + if since > status.applied_revision { + ObservedInstance::Pending + } else { + ObservedInstance::Absent } } @@ -405,6 +705,9 @@ impl SupervisorHostController { identity: HostIdentity, mut inbound: Streaming, ) -> Result>, Status> { + if !self.is_publisher() { + return Err(standby_refusal()); + } let hello = match tokio::time::timeout(HELLO_TIMEOUT, inbound.next()).await { Ok(Some(Ok(HostMessage { payload: Some(host_message::Payload::Hello(hello)), @@ -458,11 +761,15 @@ impl SupervisorHostController { /// Start a session for `hello`, superseding the current one. A hello from /// the current host process with a connection epoch that is not newer is /// a delayed or replayed connection and is refused. - fn begin_session( + pub(crate) fn begin_session( &self, identity: HostIdentity, hello: &HostHello, ) -> Result<(u64, watch::Receiver), Status> { + let publisher = lock(&self.inner.publisher); + if !publisher.active() { + return Err(standby_refusal()); + } let mut observed = lock(&self.inner.observed); if let Some(current) = &observed.session && current.instance_id == hello.instance_id @@ -524,7 +831,7 @@ impl SupervisorHostController { } /// Note that `revision` was sent on session `id`. - fn record_sent(&self, id: u64, revision: u64) { + pub(crate) fn record_sent(&self, id: u64, revision: u64) { let mut observed = lock(&self.inner.observed); if let Some(session) = observed.session.as_mut().filter(|session| session.id == id) { session.sent_revision = session.sent_revision.max(revision); @@ -534,7 +841,7 @@ impl SupervisorHostController { /// Record a report from session `id`. Reports from other sessions, acks /// of revisions never sent on this session, regressing acks, and /// oversized reports are dropped. - fn record_status(&self, id: u64, status: HostStatus) -> Result<(), &'static str> { + pub(crate) fn record_status(&self, id: u64, status: HostStatus) -> Result<(), &'static str> { let mut observed = lock(&self.inner.observed); let Some(session) = observed.session.as_ref().filter(|session| session.id == id) else { return Err("report from a superseded supervisor host session"); @@ -575,7 +882,7 @@ impl SupervisorHostController { tokio::pin!(credential_expiry); let mut revision = self.inner.revision.subscribe(); let mut sent_revision = 0; - let mut last_heard = tokio::time::Instant::now(); + let mut last_heard = Instant::now(); let mut heartbeat = tokio::time::interval(HOST_HEARTBEAT_INTERVAL); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // A send that cannot complete within the bound ends the session, so a @@ -594,11 +901,22 @@ impl SupervisorHostController { } }; loop { + let publisher = *lock(&self.inner.publisher); + if !publisher.active() || *superseded.borrow() { + return; + } + let lease_expiry = async move { + if let Publisher::Lease(deadline) = publisher { + tokio::time::sleep_until(deadline).await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::pin!(lease_expiry); let current = *revision.borrow_and_update(); - if current > sent_revision { - let Some(snapshot) = self.snapshot() else { - continue; - }; + if current > sent_revision + && let Some(snapshot) = self.snapshot() + { tracing::debug!( session = session_id, revision = snapshot.revision, @@ -606,13 +924,19 @@ impl SupervisorHostController { "sending supervisor host assignment snapshot" ); let revision_sent = snapshot.revision; - if !send(host_gateway_message::Payload::Assignments(snapshot)).await { - return; + tokio::select! { + biased; + _ = superseded.changed() => return, + () = &mut lease_expiry => return, + delivered = send(host_gateway_message::Payload::Assignments(snapshot)) => { + if !delivered { return; } + } } self.record_sent(session_id, revision_sent); sent_revision = current; } tokio::select! { + () = &mut lease_expiry => {}, changed = revision.changed() => { if changed.is_err() { return; @@ -620,13 +944,13 @@ impl SupervisorHostController { } message = inbound.next() => match message { Some(Ok(HostMessage { payload: Some(host_message::Payload::Status(status)) })) => { - last_heard = tokio::time::Instant::now(); + last_heard = Instant::now(); if let Err(reason) = self.record_status(session_id, status) { tracing::warn!(session = session_id, reason, "dropped supervisor host status report"); } } Some(Ok(HostMessage { payload: Some(host_message::Payload::Heartbeat(_)) })) => { - last_heard = tokio::time::Instant::now(); + last_heard = Instant::now(); } Some(Ok(_)) => { let _ = tx @@ -650,8 +974,13 @@ impl SupervisorHostController { tracing::warn!(session = session_id, "supervisor host stream went silent; closing it"); return; } - if !send(host_gateway_message::Payload::Heartbeat(HostGatewayHeartbeat {})).await { - return; + tokio::select! { + biased; + _ = superseded.changed() => return, + () = &mut lease_expiry => return, + delivered = send(host_gateway_message::Payload::Heartbeat(HostGatewayHeartbeat {})) => { + if !delivered { return; } + } } } _ = superseded.changed() => return, @@ -725,6 +1054,7 @@ mod tests { assignment("rt-stopped-before-list", b"old"), assignment("rt-listed", b"b"), ], + [], ), Ok(1) ); @@ -748,6 +1078,7 @@ mod tests { assignment("rt-created-before-list", b"a"), assignment("rt-listed", b"b"), ], + [], ), Ok(1) ); @@ -765,6 +1096,7 @@ mod tests { assignment("rt-created-before-list", b"a"), assignment("rt-listed", b"b"), ], + [], ), Err(AssignmentError::Conflict { baseline: 1, @@ -772,13 +1104,13 @@ mod tests { }) ); assert!(controller.assignment("rt-listed").is_none()); - assert_eq!(controller.replace_assignments(2, []), Ok(3)); + assert_eq!(controller.replace_assignments(2, [], []), Ok(3)); assert!(controller.snapshot().unwrap().instances.is_empty()); // The gateway refuses the assignment its host could not accept. let full = (0..MAX_ASSIGNED_INSTANCES).map(|index| assignment(&format!("rt-{index}"), b"x")); - assert_eq!(controller.replace_assignments(3, full), Ok(4)); + assert_eq!(controller.replace_assignments(3, full, []), Ok(4)); assert_eq!( controller.assign(assignment("rt-one-too-many", b"x")), Err(AssignmentError::Capacity) @@ -786,11 +1118,237 @@ mod tests { assert_eq!(controller.assign(assignment("rt-0", b"x")), Ok(())); } + #[tokio::test(start_paused = true)] + async fn leadership_fences_old_sessions_recovery_and_cached_observations() { + let controller = SupervisorHostController::new(); + controller + .replace_assignments(0, [assignment("rt-old", b"old")], []) + .unwrap(); + let old_baseline = controller.revision(); + controller.require_leadership(); + assert!(!controller.is_initialized()); + // A standby keeps no desired state, so creates and deletes beyond the + // host's capacity are left to the leader's durable recovery. + for index in 0..=MAX_ASSIGNED_INSTANCES { + controller + .assign(assignment(&format!("rt-standby-{index}"), b"s")) + .unwrap(); + controller.unassign(&format!("rt-standby-deleted-{index}")); + } + assert!(lock(&controller.inner.desired).pending.is_empty()); + let identity = HostIdentity { + subject: "openshell-supervisor:test".to_string(), + token_id: "token".to_string(), + expires_at: i64::MAX, + }; + let hello = HostHello { + instance_id: "boot".to_string(), + connection_epoch: 1, + }; + // The refusal is marked, and the marker survives gRPC trailers. + let refusal = controller + .begin_session(identity.clone(), &hello) + .unwrap_err(); + let mut trailers = tonic::codegen::http::HeaderMap::new(); + refusal.add_header(&mut trailers).unwrap(); + assert!(is_standby_refusal( + &Status::from_header_map(&trailers).unwrap() + )); + controller.start_publishing(Instant::now() + Duration::from_secs(30)); + assert!(!controller.is_initialized()); + assert!(controller.assignment("rt-old").is_none()); + assert!( + controller + .replace_assignments(old_baseline, [], []) + .is_err() + ); + // No authoritative empty snapshot before the new term's full recovery. + assert!(controller.snapshot().is_none()); + let (session, revoked) = controller.begin_session(identity.clone(), &hello).unwrap(); + controller.record_status(session, status(0, &[])).unwrap(); + assert_eq!(controller.observed("rt-new"), ObservedInstance::Pending); + controller + .replace_assignments(controller.revision(), [assignment("rt-new", b"new")], []) + .unwrap(); + controller.record_sent(session, controller.revision()); + controller + .record_status(session, status(controller.revision(), &["rt-new"])) + .unwrap(); + + let standby = SupervisorHostController::new(); + standby.require_leadership(); + standby.mirror_observations(Some(( + Instant::now() + Duration::from_secs(30), + controller.shared_observations().unwrap(), + ))); + assert!( + matches!(standby.observed("rt-new"), ObservedInstance::Reported(status) if status.ready) + ); + assert_eq!( + standby.observed("rt-created-on-standby"), + ObservedInstance::NoHost + ); + + // Renewing extends the local bound, but a cached standby view expires + // unless it too reads a fresh lease. Expiry is unknown, never absent. + tokio::time::advance(Duration::from_secs(20)).await; + controller.renew_publishing(Instant::now() + Duration::from_secs(30)); + tokio::time::advance(Duration::from_secs(11)).await; + assert!(controller.is_publisher()); + assert_eq!(standby.observed("rt-new"), ObservedInstance::NoHost); + // Demotion discards the term's desired state, and a recovery begun in + // the term cannot reinitialize the standby. + let stale_baseline = controller.revision(); + controller.stop_publishing(); + assert!(*revoked.borrow()); + assert!(!controller.is_initialized()); + assert!(controller.assignment("rt-new").is_none()); + assert_eq!( + controller.replace_assignments(stale_baseline, [assignment("rt-new", b"new")], []), + Err(AssignmentError::Standby) + ); + assert!(!controller.is_initialized()); + assert!(controller.record_status(session, status(0, &[])).is_err()); + assert_eq!(controller.observed("rt-new"), ObservedInstance::NoHost); + let refusal = controller + .begin_session(identity.clone(), &hello) + .unwrap_err(); + assert!(is_standby_refusal(&refusal)); + + controller.start_publishing(Instant::now() + Duration::from_secs(30)); + assert!(!controller.is_initialized()); + assert!(controller.snapshot().is_none()); + let expired_baseline = controller.revision(); + tokio::time::advance(Duration::from_secs(31)).await; + // A late renewal cannot revive an expired term, and an expired + // publisher edits nothing even before its demotion runs. + controller.renew_publishing(Instant::now() + Duration::from_secs(30)); + assert!(!controller.is_publisher()); + controller.assign(assignment("rt-late", b"l")).unwrap(); + controller.unassign("rt-late-stop"); + assert!(lock(&controller.inner.desired).pending.is_empty()); + assert_eq!( + controller.replace_assignments(expired_baseline, [], []), + Err(AssignmentError::Standby) + ); + assert!(!controller.is_initialized()); + assert!(is_standby_refusal( + &controller.begin_session(identity, &hello).unwrap_err() + )); + } + + #[test] + fn recovery_exclusions_become_absent_once_acknowledged_and_bound_capacity() { + let controller = SupervisorHostController::new(); + let identity = HostIdentity { + subject: "openshell-supervisor:test".to_string(), + token_id: "token".to_string(), + expires_at: i64::MAX, + }; + let hello = HostHello { + instance_id: "boot".to_string(), + connection_epoch: 1, + }; + let (session, _) = controller.begin_session(identity, &hello).unwrap(); + let listed = || { + (0..MAX_ASSIGNED_INSTANCES + 2) + .map(|index| assignment(&format!("rt-{index:03}"), b"x")) + .collect::>() + }; + // Beyond capacity, nothing is cut until the host reports what it runs. + assert_eq!( + controller.replace_assignments(0, listed(), ["rt-gone".to_string()]), + Err(AssignmentError::Capacity) + ); + assert!(!controller.is_initialized()); + + // The running instance stays; the excess is excluded in route order. + controller + .record_status(session, status(0, &["rt-257"])) + .unwrap(); + assert_eq!( + controller.replace_assignments(0, listed(), ["rt-gone".to_string()]), + Ok(1) + ); + assert_eq!( + controller.snapshot().unwrap().instances.len(), + MAX_ASSIGNED_INSTANCES + ); + assert!(controller.assignment("rt-257").is_some()); + let excluded = ["rt-gone", "rt-255", "rt-256"]; + for route in excluded { + assert_eq!(controller.observed(route), ObservedInstance::Pending); + } + controller.record_sent(session, 1); + controller + .record_status(session, status(1, &["rt-257"])) + .unwrap(); + for route in excluded { + assert_eq!(controller.observed(route), ObservedInstance::Absent); + } + assert_eq!( + controller.observed("rt-undiscovered"), + ObservedInstance::Pending + ); + let observations = controller.shared_observations().unwrap(); + assert_eq!( + observations.get("rt-gone"), + Some(&SharedObservation::Absent) + ); + assert!(!observations.contains_key("rt-undiscovered")); + + // A later recovery keeps current assignments over a new route that + // sorts first, and an unchanged set needs no new snapshot or ack. + let revision = controller.inner.revision.subscribe(); + let mut relisted = listed(); + relisted.push(assignment("rt-0", b"x")); + assert_eq!( + controller.replace_assignments(1, relisted, ["rt-gone".to_string()]), + Ok(1) + ); + assert!(!revision.has_changed().unwrap()); + assert_eq!(controller.observed("rt-0"), ObservedInstance::Absent); + assert_eq!(controller.observed("rt-gone"), ObservedInstance::Absent); + + // Withdrawing or reassigning an excluded route clears its verdict, a + // raced recovery records nothing, and restored material assigns it. + // A withdrawal fences recovery listed before it, so a stale list + // cannot re-admit an excluded or undiscovered withdrawn route. + let stale = controller.revision(); + controller.unassign("rt-255"); + controller.unassign("rt-undiscovered"); + assert_eq!(controller.observed("rt-255"), ObservedInstance::Pending); + let stale_list = [ + assignment("rt-255", b"x"), + assignment("rt-undiscovered", b"x"), + ]; + assert!(matches!( + controller.replace_assignments(stale, stale_list, []), + Err(AssignmentError::Conflict { .. }) + )); + assert!(controller.assignment("rt-255").is_none()); + assert!(controller.assignment("rt-undiscovered").is_none()); + controller.unassign("rt-000"); + controller.assign(assignment("rt-256", b"x")).unwrap(); + assert_eq!(controller.observed("rt-256"), ObservedInstance::Pending); + assert!( + controller + .replace_assignments(1, [], ["rt-raced".to_string()]) + .is_err() + ); + assert_eq!(controller.observed("rt-raced"), ObservedInstance::Pending); + controller + .replace_assignments(controller.revision(), [assignment("rt-gone", b"x")], []) + .unwrap(); + assert_eq!(controller.observed("rt-gone"), ObservedInstance::Pending); + assert_eq!(controller.observed("rt-0"), ObservedInstance::Pending); + } + #[test] fn sessions_fence_stale_streams_epochs_and_acks() { let controller = SupervisorHostController::new(); controller - .replace_assignments(0, [assignment("rt-a", b"a")]) + .replace_assignments(0, [assignment("rt-a", b"a")], []) .unwrap(); assert_eq!(controller.observed("rt-a"), ObservedInstance::NoHost); @@ -836,7 +1394,7 @@ mod tests { // A newer assignment is pending until the host reconciles past it. controller.assign(assignment("rt-b", b"b")).unwrap(); assert_eq!(controller.observed("rt-b"), ObservedInstance::Pending); - assert_eq!(controller.observed("rt-unknown"), ObservedInstance::Absent); + assert_eq!(controller.observed("rt-unknown"), ObservedInstance::Pending); controller.record_sent(current, 2); controller.record_status(current, status(2, &[])).unwrap(); assert_eq!(controller.observed("rt-b"), ObservedInstance::Absent); diff --git a/crates/openshell-supervisor-control/src/coordinator.rs b/crates/openshell-supervisor-control/src/coordinator.rs index cedbb42bc2..16b9443216 100644 --- a/crates/openshell-supervisor-control/src/coordinator.rs +++ b/crates/openshell-supervisor-control/src/coordinator.rs @@ -40,8 +40,9 @@ pub enum LaunchError { /// The driver could not persist the launch material; nothing was assigned. #[error("{0}")] Persist(E), - /// The host is at capacity. The material was persisted; the caller rolls - /// the generation back before its workload runs. + /// The publishing gateway's host is at capacity. The material was + /// persisted; the caller rolls the generation back before its workload + /// runs. #[error("{0}")] Refused(AssignmentError), } @@ -68,11 +69,12 @@ pub struct RecoverableInstance { pub enum RecoveryOutcome { /// The desired set matches the records; carries the revision in force. Published(u64), - /// The desired set changed while the records were listed; the next pass - /// derives them again. + /// The desired set changed, or this replica stopped publishing, while + /// the records were listed; the next pass derives them again. Conflict, /// Launch material could not be read or does not match its record, or the - /// set exceeds the host's capacity; the previous set stays published. + /// set exceeds the host's capacity before the host has reported what it + /// runs; the previous set stays published. Kept, } @@ -84,8 +86,9 @@ pub enum InstanceSupervision { /// The host has not reconciled to the assigning snapshot yet, or hosts /// the instance but it is still starting. Degraded, - /// The host reconciled past the assignment and does not host the - /// instance, hosts it for another sandbox, or reports it ended. + /// The host reconciled past the assignment, or past the recovery that + /// left the instance unassigned, and does not host the instance, hosts + /// it for another sandbox, or reports it ended. Unavailable, /// No host is connected, so nothing is known. Unknown, @@ -127,8 +130,12 @@ impl SharedSupervision { /// assign the instance so the host starts it. The launch material is /// persisted first so a restarted gateway recovers the assignment from the /// generation's own records. An identical assignment changes nothing. - /// Capacity is refused as [`LaunchError::Refused`], so the caller fails - /// its create before the workload runs. + /// On the publishing gateway, capacity is refused as + /// [`LaunchError::Refused`], so the caller fails its create before the + /// workload runs. On a replicated gateway's standby, admission is + /// eventual: the leader's recovery assigns the persisted instance, or + /// leaves it unassigned beyond capacity so its supervision becomes + /// unavailable. pub async fn launch( &self, registration: &SandboxInstanceRegistration, @@ -149,7 +156,8 @@ impl SharedSupervision { } /// Withdraw a generation's sandbox instance so the host stops it. Nothing - /// happens for a route that is not assigned. + /// happens for a route that is not assigned; a standby leaves the stop to + /// the leader's recovery from durable records. pub fn withdraw(&self, route: &str) { self.controller.unassign(route); } @@ -175,8 +183,10 @@ impl SharedSupervision { /// The first completed pass initializes the desired set; until then /// nothing is sent to the host. A failed or mismatched read keeps the /// previous set, because an incomplete set would stop running instances. - /// A pass that raced a lifecycle edit is refused and derived again next - /// time. + /// A pass that raced a lifecycle edit or the end of this replica's term + /// is refused and derived again next time. A candidate whose material is + /// gone, or that exceeds the host's capacity, is left unassigned and + /// becomes absent once the host acknowledges a snapshot omitting it. pub async fn complete_recovery( &self, baseline: RecoveryBaseline, @@ -189,6 +199,7 @@ impl SharedSupervision { F: Future>, E>> + Send, { let mut assignments = Vec::with_capacity(candidates.len()); + let mut excluded = Vec::new(); for candidate in &candidates { if let Some(assignment) = self.controller.assignment(&candidate.route) { assignments.push(assignment); @@ -202,6 +213,7 @@ impl SharedSupervision { route = %candidate.route, "shared sandbox instance has no launch material; it stays unassigned" ); + excluded.push(candidate.route.clone()); continue; } Err(error) => { @@ -229,10 +241,12 @@ impl SharedSupervision { } match self .controller - .replace_assignments(baseline.revision, assignments) + .replace_assignments(baseline.revision, assignments, excluded) { Ok(revision) => RecoveryOutcome::Published(revision), - Err(AssignmentError::Conflict { .. }) => RecoveryOutcome::Conflict, + Err(AssignmentError::Conflict { .. } | AssignmentError::Standby) => { + RecoveryOutcome::Conflict + } Err(error) => { tracing::warn!(%error, "could not publish the shared sandbox instances; keeping the previous assignments"); RecoveryOutcome::Kept @@ -300,7 +314,8 @@ fn recovered_assignment( #[cfg(test)] mod tests { use super::*; - use crate::proto::SandboxInstanceAssignment; + use crate::controller::HostIdentity; + use crate::proto::{HostHello, HostStatus, SandboxInstanceAssignment}; fn candidate(route: &str, sandbox_id: &str) -> RecoverableInstance<&'static str> { RecoverableInstance { @@ -369,6 +384,56 @@ mod tests { Some(assigned.clone()) ); + // The skipped record is pending until the host acknowledges the + // snapshot omitting it, then unavailable, unlike an undiscovered + // route. A later failed read is no evidence and changes nothing. + let controller = supervision.controller(); + let (session, _) = controller + .begin_session( + HostIdentity { + subject: "openshell-supervisor:test".to_string(), + token_id: "token".to_string(), + expires_at: i64::MAX, + }, + &HostHello { + instance_id: "boot".to_string(), + connection_epoch: 1, + }, + ) + .unwrap(); + let acked = |applied_revision| HostStatus { + applied_revision, + instances: Vec::new(), + }; + controller.record_status(session, acked(0)).unwrap(); + assert_eq!( + supervision.supervision("rt-gone-0123456789", "sandbox-d"), + InstanceSupervision::Degraded + ); + controller.record_sent(session, 1); + controller.record_status(session, acked(1)).unwrap(); + assert_eq!( + supervision.supervision("rt-gone-0123456789", "sandbox-d"), + InstanceSupervision::Unavailable + ); + assert_eq!( + supervision.supervision("rt-undiscovered-0123456789", "sandbox-e"), + InstanceSupervision::Degraded + ); + let baseline = supervision.begin_recovery(); + let outcome = supervision + .complete_recovery( + baseline, + vec![candidate("rt-gone-0123456789", "sandbox-d")], + |_record| async { Err::>, _>("boom") }, + ) + .await; + assert_eq!(outcome, RecoveryOutcome::Kept); + assert_eq!( + supervision.supervision("rt-gone-0123456789", "sandbox-d"), + InstanceSupervision::Unavailable + ); + // A baseline read before a lifecycle edit is refused, and the edit stands. let baseline = supervision.begin_recovery(); supervision.withdraw(&assigned.route); diff --git a/crates/openshell-supervisor-control/src/lib.rs b/crates/openshell-supervisor-control/src/lib.rs index 4401ff3d84..ae62365067 100644 --- a/crates/openshell-supervisor-control/src/lib.rs +++ b/crates/openshell-supervisor-control/src/lib.rs @@ -22,7 +22,8 @@ //! //! The private wire protocol is generated in `openshell-core` and re-exported //! as [`proto`]. The supervisor process consumes the registration and status -//! types; it never links the controller or coordinator. +//! types and the private standby refusal marker ([`is_standby_refusal`]); it +//! never uses the controller's desired state or the coordinator. pub mod controller; pub mod coordinator; @@ -38,7 +39,8 @@ pub mod proto { pub use controller::{ AssignmentError, HOST_HEARTBEAT_INTERVAL, HostIdentity, MAX_ASSIGNED_INSTANCES, - MAX_REPORTED_INSTANCES, ObservedInstance, SupervisorCredential, SupervisorHostController, + MAX_REPORTED_INSTANCES, ObservedInstance, STANDBY_REFUSAL_METADATA, STANDBY_REFUSAL_VALUE, + SharedObservation, SupervisorCredential, SupervisorHostController, is_standby_refusal, }; pub use coordinator::{ InstanceSupervision, LaunchError, RecoverableInstance, RecoveryBaseline, RecoveryOutcome, diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index 9083f62d74..1e99003ff4 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -171,7 +171,8 @@ control listener: it dials the gateway and receives its assignments from it. - **Disconnects.** Losing the gateway stream never stops an instance. The host reconnects with backoff, reports what it runs, and applies the first snapshot of the new session; only a snapshot that omits an instance, or - process shutdown, stops it. + process shutdown, stops it. A standby gateway replica's marked refusal is + retried after a short jittered delay instead, to reach the leader. - **Runtime connections.** One routed listener (`--runtime-listen`) accepts every instance's runtime-initiated Sandbox Protocol connections; see `openshell-sandbox-backend` for the routing preface. diff --git a/crates/openshell-supervisor/src/shared_host.rs b/crates/openshell-supervisor/src/shared_host.rs index a0dfd133ee..51d5eb9808 100644 --- a/crates/openshell-supervisor/src/shared_host.rs +++ b/crates/openshell-supervisor/src/shared_host.rs @@ -55,6 +55,11 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const SEND_TIMEOUT: Duration = Duration::from_secs(30); const RECONNECT_BACKOFF_MIN: Duration = Duration::from_secs(1); const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(30); +/// Delay, plus up to [`STANDBY_RETRY_JITTER`], before reconnecting after a +/// standby gateway replica refused the host, so a load-balanced endpoint +/// reaches the reconciler leader promptly without synchronized retries. +const STANDBY_RETRY_MIN: Duration = Duration::from_millis(250); +const STANDBY_RETRY_JITTER: Duration = Duration::from_millis(750); /// Host-to-gateway messages queued before the stream applies backpressure. const OUTBOUND_QUEUE: usize = 16; /// Most sandbox instances one snapshot may assign to a host; the gateway @@ -492,11 +497,38 @@ enum StreamEnd { /// The gateway rejected or closed the stream; reconnect after a backoff. /// `accepted` records whether the gateway had accepted the host. Lost { accepted: bool }, + /// A gateway replica that does not publish assignments refused the host; + /// reconnect promptly on a fresh connection to reach the leader. + Standby, +} + +/// Delay before the next connection attempt. A standby refusal waits a +/// short jittered delay and leaves the backoff alone; any other loss waits +/// out the backoff, which restarts from its minimum once the gateway had +/// accepted the host and doubles up to its cap otherwise. +fn reconnect_delay(end: &StreamEnd, backoff: &mut Duration) -> Duration { + match end { + StreamEnd::Standby => { + let jitter_ms = u64::try_from(STANDBY_RETRY_JITTER.as_millis()) + .unwrap_or(1) + .max(1); + let jitter = uuid::Uuid::new_v4().as_u64_pair().0 % jitter_ms; + STANDBY_RETRY_MIN + Duration::from_millis(jitter) + } + StreamEnd::Lost { accepted } => { + if *accepted { + *backoff = RECONNECT_BACKOFF_MIN; + } + let delay = *backoff; + *backoff = (*backoff * 2).min(RECONNECT_BACKOFF_MAX); + delay + } + } } /// Keep one assignment stream open to the gateway until `shutdown` -/// completes, reconnecting with backoff. Hosted instances run on across -/// disconnects. +/// completes, reconnecting with backoff, or promptly after a standby +/// replica's refusal. Hosted instances run on across disconnects. pub async fn attach_to_gateway( host: Arc, link: GatewayLink, @@ -512,22 +544,25 @@ pub async fn attach_to_gateway( instance_id: instance_id.clone(), connection_epoch, }; - let StreamEnd::Lost { accepted } = tokio::select! { + let end = tokio::select! { end = run_stream(&host, &link, hello) => end, () = &mut shutdown => return, }; - if accepted { - backoff = RECONNECT_BACKOFF_MIN; + let delay = reconnect_delay(&end, &mut backoff); + match end { + StreamEnd::Standby => tracing::debug!( + retry_in_ms = delay.as_millis(), + "gateway replica is a standby; reconnecting to reach the reconciler leader" + ), + StreamEnd::Lost { .. } => tracing::info!( + retry_in_secs = delay.as_secs(), + "gateway assignment stream lost; hosted sandbox instances keep running" + ), } - tracing::info!( - retry_in_secs = backoff.as_secs(), - "gateway assignment stream lost; hosted sandbox instances keep running" - ); tokio::select! { - () = tokio::time::sleep(backoff) => {} + () = tokio::time::sleep(delay) => {} () = &mut shutdown => return, } - backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); } } @@ -580,6 +615,9 @@ async fn run_stream(host: &Arc, link: &GatewayLink, hello: HostHello let attach = client.attach(tokio_stream::wrappers::ReceiverStream::new(rx)); let mut inbound = match tokio::time::timeout(CONNECT_TIMEOUT, attach).await { Ok(Ok(response)) => response.into_inner(), + Ok(Err(status)) if openshell_supervisor_control::is_standby_refusal(&status) => { + return StreamEnd::Standby; + } Ok(Err(status)) => { tracing::warn!(%status, "gateway refused the supervisor host stream"); return lost(false); @@ -722,3 +760,39 @@ async fn run_stream(host: &Arc, link: &GatewayLink, hello: HostHello } end } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standby_refusals_retry_promptly_and_other_losses_back_off() { + let mut backoff = RECONNECT_BACKOFF_MIN; + let lost = StreamEnd::Lost { accepted: false }; + assert_eq!(reconnect_delay(&lost, &mut backoff), Duration::from_secs(1)); + assert_eq!(reconnect_delay(&lost, &mut backoff), Duration::from_secs(2)); + + // Standby refusals stay short, jittered, and leave the outage backoff + // where it was. + for _ in 0..32 { + let delay = reconnect_delay(&StreamEnd::Standby, &mut backoff); + assert!(delay >= STANDBY_RETRY_MIN); + assert!(delay < STANDBY_RETRY_MIN + STANDBY_RETRY_JITTER); + } + assert_eq!(reconnect_delay(&lost, &mut backoff), Duration::from_secs(4)); + for _ in 0..8 { + reconnect_delay(&lost, &mut backoff); + } + assert_eq!(reconnect_delay(&lost, &mut backoff), RECONNECT_BACKOFF_MAX); + assert_eq!( + reconnect_delay(&StreamEnd::Lost { accepted: true }, &mut backoff), + RECONNECT_BACKOFF_MIN + ); + + // Only the gateway's marked refusal counts as a standby; a bare + // UNAVAILABLE is an outage. + assert!(!openshell_supervisor_control::is_standby_refusal( + &tonic::Status::unavailable("reconnect to the gateway reconciler leader") + )); + } +} diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index bee5b79011..6e64e8b67c 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -687,6 +687,23 @@ returns `UNAVAILABLE` with "lock sandbox SSH identity failed", and neither `openshell_server_mutation_lock_timeouts_total` nor `openshell_server_mutation_lock_errors_total` counts it. +With `supervisor_topology = "gateway"` and replicated gateway storage, the +shared supervisor's assignment stream belongs to the existing sandbox +reconciler lease holder. Look for `acquired reconciler lease` and `supervisor +host attached` on the same gateway Pod. A standby returns `UNAVAILABLE` with +`reconnect to the gateway reconciler leader`; the supervisor retries using a +fresh connection. Its gateway endpoint must balance across eligible replicas; +remove session affinity that pins retries to a standby. Hosted workloads +continue running during reconnection. After takeover, the new leader recovers +durable assignments before sending its first snapshot. + +Creates through a standby can take an extra driver reconciliation interval +before the leader assigns their instances. The standby reads readiness from +observations carried by the reconciler lease, refreshed on renewal (10 +seconds) and polled every 5 seconds. An expired or unreadable lease means +unknown supervision, not instance failure. Check gateway storage connectivity +and lease-renewal logs when readiness lags across all sandboxes. + For multi-replica gateway installs, supervisor and client session traffic may be served by a non-owner gateway replica and relayed to the current supervisor owner over the internal `PeerRelay` RPC. Check the headless peer Service,