From 132dfcf352567d6016446097bc250db6a40c8a03 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Mon, 14 Sep 2026 10:57:18 -0700 Subject: [PATCH 1/3] feat(api): add durable workspace mutation admission and replay Part of #3051 (phase 3a). Preserve unresolved admissions, reauthorize replay, and protect same-name replacements. Signed-off-by: Mrunal Patel --- architecture/gateway.md | 24 +- crates/openshell-cli/src/run.rs | 6 + crates/openshell-sdk/src/client.rs | 6 + crates/openshell-server/src/grpc/mod.rs | 13 +- .../src/grpc/mutation_replay.rs | 589 +++++++++++++ .../src/grpc/mutation_replay/tests.rs | 825 ++++++++++++++++++ .../src/grpc/mutation_tests.rs | 1 + crates/openshell-server/src/grpc/provider.rs | 2 + crates/openshell-server/src/grpc/sandbox.rs | 15 + crates/openshell-server/src/grpc/service.rs | 1 + crates/openshell-server/src/grpc/workspace.rs | 64 +- crates/openshell-server/src/storage_proto.rs | 2 +- docs/reference/api-errors.mdx | 57 ++ e2e/python/test_workspace_api.py | 40 + proto/openshell.proto | 13 + sdk/go/proto/openshellv1/openshell.pb.go | 109 ++- 16 files changed, 1727 insertions(+), 40 deletions(-) create mode 100644 crates/openshell-server/src/grpc/mutation_replay.rs create mode 100644 crates/openshell-server/src/grpc/mutation_replay/tests.rs diff --git a/architecture/gateway.md b/architecture/gateway.md index 5e04f377fa..bfc1df66ba 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -62,6 +62,22 @@ transport status, metadata, and unknown details alongside decoded fields. SDK deletion waits recognize missing-resource status through typed error wrappers without suppressing other failures. +Workspace lifecycle, membership, and sandbox-template mutations explicitly opt +into durable request admission when the client supplies a UUID. Typed adapters +check current authorization before looking up a caller/method/workspace-scoped +key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps. +An atomic, quota-checked insert chooses one executor; owned execution survives +client cancellation. Success is persisted before acknowledgment. Errors or +interruption leave permanent unresolved claims, never stealable leases. + +Admission rows live outside user workspace namespaces and are bounded per caller. +Successes expire after 24 hours; cleanup uses the unique admission incarnation +and version so an old cleaner cannot delete a new attempt. Replay stores only +resource UUID/version references or deletion outcomes. It checks the original +workspace identity and current authorization, and never substitutes a same-name +resource. Intercepted mutations and credential capabilities require separate +adapters; this mechanism does not replay streams or repeat interceptor observers. + The gateway listens on one service port and multiplexes gRPC and HTTP traffic. The default local single-user deployment mode is mTLS user authentication: clients present a certificate signed by the local deployment CA, and the @@ -391,7 +407,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests failures remain errors. Already-revoked sessions complete without another write after current authorization. The removed response booleans are reserved by name and number; this coordinated pre-1.0 API change does not alter durable schemas. -It does not add request deduplication or identity preconditions for later retries. +The outcome alone does not provide request deduplication. The six opted-in +workspace/template methods require a request UUID for the admission contract. | Dual-purpose encoded root | Current decision | |---|---| @@ -442,6 +459,11 @@ populate `scope`, `version`, `status`, `dedup_key`, and `hit_count` so the gateway can efficiently fetch the latest policy, track load status, and manage advisor drafts without creating resource-specific tables. +Mutation admission uses a private, version-tagged JSON envelope in the same +object store. Its identity namespace stays stable across format changes, and an +unknown format fails closed. It contains no public response payloads and is not +part of the protobuf storage closure. + Each sandbox policy revision stores the complete provenance annotation map supplied with that update. The revision payload is the authoritative immutable record; sandbox metadata receives the same annotations only as a convenience diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 9dbbe763b5..dd3d1edc5d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2685,6 +2685,7 @@ pub async fn sandbox_template_create( let mut client = grpc_client(server, tls).await?; let response = client .create_sandbox_template(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(SandboxWorkloadTemplate { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2872,6 +2873,7 @@ pub async fn sandbox_template_delete( for name in names { let response = client .delete_sandbox_template(DeleteSandboxTemplateRequest { + request_id: String::new(), allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -3827,6 +3829,7 @@ pub async fn workspace_create( let mut client = grpc_client(server, tls).await?; let response = client .create_workspace(CreateWorkspaceRequest { + request_id: String::new(), name: name.to_string(), labels, }) @@ -3981,6 +3984,7 @@ pub async fn workspace_delete(server: &str, names: &[String], tls: &TlsOptions) for name in names { let response = client .delete_workspace(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: name.clone(), }) @@ -4018,6 +4022,7 @@ pub async fn workspace_member_add( let mut client = grpc_client(server, tls).await?; let response = client .add_workspace_member(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: workspace.to_string(), principal_subject: subject.to_string(), role: role_val.into(), @@ -4052,6 +4057,7 @@ pub async fn workspace_member_remove( let mut client = grpc_client(server, tls).await?; let response = client .remove_workspace_member(RemoveWorkspaceMemberRequest { + request_id: String::new(), allow_missing: true, workspace: workspace.to_string(), principal_subject: subject.to_string(), diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 47f30dfe67..b98e34cc61 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -170,6 +170,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template.clone()), workspace_scope: Some(proto::workspace_selector("default")), }; @@ -282,6 +283,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::DeleteSandboxTemplateRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), @@ -509,6 +511,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::CreateWorkspaceRequest { + request_id: String::new(), name: name.to_string(), labels: labels.clone(), }; @@ -582,6 +585,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), }; @@ -796,6 +800,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template.clone()), workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; @@ -871,6 +876,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::DeleteSandboxTemplateRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index f66a0a011a..d3bb7119b7 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -4,6 +4,7 @@ //! gRPC service implementation. mod auth_rpc; +mod mutation_replay; pub mod policy; pub mod provider; mod sandbox; @@ -303,7 +304,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_create_sandbox_template(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_sandbox_template( @@ -324,7 +325,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_delete_sandbox_template(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn list_sandbox_providers( @@ -754,7 +755,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - workspace::handle_create_workspace(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_workspace( @@ -775,21 +776,21 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - workspace::handle_delete_workspace(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn add_workspace_member( &self, request: Request, ) -> Result, Status> { - workspace::handle_add_workspace_member(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn remove_workspace_member( &self, request: Request, ) -> Result, Status> { - workspace::handle_remove_workspace_member(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn list_workspace_members( diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs new file mode 100644 index 0000000000..4e2cc78c7e --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -0,0 +1,589 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Explicitly opted-in unary mutations. This is an admission fence, not a lease: +//! an owner that cannot persist success leaves an unresolved claim forever. +//! Intercepted and credential-bearing RPCs must not use this adapter unchanged. + +#![allow(clippy::result_large_err)] + +use std::collections::BTreeMap; +use std::sync::{Arc, LazyLock}; + +use openshell_core::proto::{ + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateSandboxTemplateRequest, + CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, SandboxTemplateResponse, + Workspace, WorkspaceSelector, +}; +use openshell_core::{GetResourceVersion, ObjectId, rpc_error}; +use prost::Message; +use prost_reflect::{DescriptorPool, DynamicMessage}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::Semaphore; +use tonic::{Request, Response, Status}; + +use super::{sandbox, workspace}; +use crate::ServerState; +use crate::auth::identity::IdentityProvider; +use crate::auth::principal::Principal; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, authorize_workspace, authorize_workspace_selector, require_platform_admin, +}; +use crate::persistence::{ + ObjectType, PersistenceError, SetResourceVersion, Store, WriteCondition, current_time_ms, +}; + +const OBJECT_TYPE: &str = "mutation_admission_v1"; +const SUCCESS_TTL_MS: i64 = 24 * 60 * 60 * 1000; +const MAX_ADMISSIONS_PER_CALLER: u32 = 1000; +// Bound work detached from request cancellation across all service instances. +static EXECUTORS: LazyLock> = LazyLock::new(|| Arc::new(Semaphore::new(64))); +static DESCRIPTORS: LazyLock = LazyLock::new(|| { + DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("compiled protobuf descriptor set") +}); + +#[derive(Serialize, Deserialize)] +struct Admission { + // Version the canonicalization separately from the namespace. A future + // change must reject incompatible records, not admit the old key again. + format_version: u32, + payload_hash: String, + workspace_id: Option, + success: Option, + completed_at_ms: Option, +} + +/// Deliberately no serialized requests, responses, tokens, or error messages. +#[derive(Serialize, Deserialize)] +pub(super) enum Success { + Resource { id: String, version: u64 }, + Deletion { outcome: i32 }, +} + +pub(super) struct Scope { + name: String, + workspace_id: Option, +} + +#[tonic::async_trait] +pub(super) trait Mutation: Message + Default + Send + Sync + 'static { + type Output: Message + Default + Send + 'static; + const METHOD: &'static str; + fn request_id(&self) -> &str; + async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result; + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status>; + fn capture(response: &Self::Output) -> Result; + async fn restore(store: &Store, success: Success) -> Result; +} + +/// Empty IDs preserve the existing RPC contract, including cancellation. +pub(super) async fn run( + state: &Arc, + request: Request, +) -> Result, Status> { + if request.get_ref().request_id().is_empty() { + return M::execute(state, request).await; + } + if request.get_ref().encoded_len() > 4 * 1024 * 1024 { + return Err(Status::resource_exhausted("mutation request exceeds 4 MiB")); + } + let request_id = validate_request_id(request.get_ref().request_id())?; + let principal = super::extract_principal(&request)?; + let Principal::User(user) = &principal else { + return Err(Status::permission_denied( + "request admission requires a user principal", + )); + }; + let scope = request.get_ref().authorize(state, &principal).await?; + let (provider, issuer) = match user.identity.provider { + IdentityProvider::Oidc => ( + "oidc", + state + .config + .oidc + .as_ref() + .map_or("", |config| config.issuer.as_str()), + ), + IdentityProvider::Mtls => ("mtls", ""), + IdentityProvider::CloudflareAccess => ("cloudflare_access", ""), + IdentityProvider::LocalDev => ("local_dev", ""), + }; + let caller = hash_json(&serde_json::json!([ + provider, + issuer, + user.identity.subject, + ]))?; + // Not a valid user workspace name; ordinary workspace cleanup cannot touch + // these records. The bucket supports an atomic per-caller storage quota. + let bucket = format!("_mutation/{caller}"); + let key = hash_json(&serde_json::json!([ + caller, + M::METHOD, + scope.name, + request_id + ]))?; + let payload_hash = fingerprint(request.get_ref())?; + let permit = EXECUTORS.clone().try_acquire_owned().map_err(|_| { + Status::resource_exhausted( + "mutation admission workers are busy; no work was started by this call", + ) + })?; + let state = Arc::clone(state); + // Spawn BEFORE attempting MustCreate. Cancellation during the database write + // must not drop the owner between durable admission and execution. + tokio::spawn(async move { + let _permit = permit; + execute_owned(&state, request, &key, &bucket, payload_hash, scope).await + }) + .await + .map_err(|_| uncertain())? +} + +async fn execute_owned( + state: &Arc, + request: Request, + key: &str, + bucket: &str, + payload_hash: String, + scope: Scope, +) -> Result, Status> { + let mut admission = Admission { + format_version: 1, + payload_hash, + workspace_id: scope.workspace_id, + success: None, + completed_at_ms: None, + }; + // Contention is bounded. Losing a race never authorizes execution. + for _ in 0..8 { + if let Some(row) = state + .store + .get_by_name(OBJECT_TYPE, bucket, key) + .await + .map_err(storage_error)? + { + let previous: Admission = serde_json::from_slice(&row.payload) + .map_err(|_| Status::internal("invalid durable mutation admission"))?; + if previous.format_version != 1 { + return Err(replay_unavailable()); + } + if previous.success.is_some() + && previous.completed_at_ms.is_some_and(|completed| { + current_time_ms() >= completed.saturating_add(SUCCESS_TTL_MS) + }) + { + match state + .store + .delete_if(OBJECT_TYPE, &row.id, row.resource_version) + .await + { + Ok(_) | Err(PersistenceError::Conflict { .. }) => continue, + Err(error) => return Err(storage_error(error)), + } + } + if previous.payload_hash != admission.payload_hash { + return Err(rpc_error::failed_precondition( + "REQUEST_ID_PAYLOAD_MISMATCH", + "request_id was already used with a different payload", + )); + } + if previous.workspace_id != admission.workspace_id { + return Err(replay_unavailable()); + } + let success = previous.success.ok_or_else(uncertain)?; + let mut response = Response::new(M::restore(&state.store, success).await?); + response.metadata_mut().insert( + "openshell-replayed", + "true".parse().expect("static metadata"), + ); + return Ok(response); + } + let payload = serde_json::to_vec(&admission) + .map_err(|_| Status::internal("encode mutation admission"))?; + // A new incarnation needs a new row ID. Versions reset after deletion; + // an expiry cleaner holding an old snapshot must not delete a new claim. + let claim_id = uuid::Uuid::new_v4().to_string(); + let write = state + .store + .create_if_workspace_count_below( + OBJECT_TYPE, + &claim_id, + key, + bucket, + &payload, + None, + u64::from(MAX_ADMISSIONS_PER_CALLER), + ) + .await; + let version = match write { + Ok(Some(write)) => write.resource_version, + Ok(None) => { + if prune_expired(&state.store, bucket).await? { + continue; + } + return Err(Status::resource_exhausted( + "caller has reached the durable mutation admission limit; unresolved requests require reconciliation", + )); + } + Err(PersistenceError::UniqueViolation { .. } | PersistenceError::Conflict { .. }) => { + continue; + } + Err(error) => return Err(storage_error(error)), + }; + // Any error or panic from here leaves the claim unresolved. Status codes + // do not establish that a handler performed no effects. + let response = M::execute(state, request).await?; + admission.success = Some(M::capture(response.get_ref())?); + admission.completed_at_ms = Some(current_time_ms()); + let payload = serde_json::to_vec(&admission).map_err(|_| uncertain())?; + state + .store + .put_if( + OBJECT_TYPE, + &claim_id, + key, + bucket, + &payload, + None, + WriteCondition::MatchResourceVersion(version), + ) + .await + .map_err(|_| uncertain())?; + return Ok(response); + } + Err(uncertain()) +} + +async fn prune_expired(store: &Store, bucket: &str) -> Result { + // The bucket is atomically bounded, so this scan cannot grow without limit. + let rows = store + .list(OBJECT_TYPE, bucket, MAX_ADMISSIONS_PER_CALLER, 0) + .await + .map_err(storage_error)?; + let mut removed = false; + for row in rows { + let Ok(admission) = serde_json::from_slice::(&row.payload) else { + continue; + }; + if admission.format_version == 1 + && admission.success.is_some() + && admission.completed_at_ms.is_some_and(|completed| { + current_time_ms() >= completed.saturating_add(SUCCESS_TTL_MS) + }) + { + match store + .delete_if(OBJECT_TYPE, &row.id, row.resource_version) + .await + { + Ok(deleted) => removed |= deleted, + Err(PersistenceError::Conflict { .. }) => {} + Err(error) => return Err(storage_error(error)), + } + } + } + Ok(removed) +} + +fn validate_request_id(value: &str) -> Result { + let invalid = || { + rpc_error::invalid_argument( + "request_id", + "must be a nonzero hyphenated UUID (36 characters)", + ) + }; + if value.len() != 36 { + return Err(invalid()); + } + let id = uuid::Uuid::parse_str(value).map_err(|_| invalid())?; + if id.is_nil() || !value.eq_ignore_ascii_case(&id.hyphenated().to_string()) { + return Err(invalid()); + } + Ok(id.hyphenated().to_string()) +} + +fn fingerprint(request: &M) -> Result { + let descriptor = DESCRIPTORS + .get_message_by_name(&format!("openshell.v1.{}Request", M::METHOD)) + .ok_or_else(|| Status::internal("mutation request descriptor missing"))?; + let mut message = DynamicMessage::decode(descriptor, request.encode_to_vec().as_slice()) + .map_err(|_| Status::internal("decode mutation request"))?; + message.clear_field_by_name("request_id"); + let value = serde_json::to_value(message) + .map_err(|_| Status::internal("canonicalize mutation request"))?; + hash_json(&value) +} + +// Sort every map explicitly; do not depend on serde_json's preserve_order feature. +fn hash_json(value: &serde_json::Value) -> Result { + struct Canonical<'a>(&'a serde_json::Value); + impl Serialize for Canonical<'_> { + fn serialize(&self, serializer: S) -> Result { + match self.0 { + serde_json::Value::Object(map) => map + .iter() + .map(|(key, value)| (key, Canonical(value))) + .collect::>() + .serialize(serializer), + serde_json::Value::Array(values) => values + .iter() + .map(Canonical) + .collect::>() + .serialize(serializer), + value => value.serialize(serializer), + } + } + } + let bytes = serde_json::to_vec(&Canonical(value)) + .map_err(|_| Status::internal("canonicalize mutation payload"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +fn uncertain() -> Status { + rpc_error::failed_precondition( + "REQUEST_OUTCOME_UNCERTAIN", + "request is admitted but has no confirmed replayable success; reconcile effects before starting a new request", + ) +} + +fn replay_unavailable() -> Status { + rpc_error::failed_precondition( + "REQUEST_REPLAY_UNAVAILABLE", + "the original workspace or result no longer exists at its recorded version; this request was not executed again", + ) +} + +fn storage_error(error: PersistenceError) -> Status { + tracing::warn!(%error, "mutation admission storage failed"); + Status::internal("mutation admission storage failed") +} + +async fn named_scope(state: &ServerState, name: &str) -> Result { + let name = if name.is_empty() { + workspace::DEFAULT_WORKSPACE_NAME + } else { + name + }; + let resource: Workspace = state + .store + .get_message_by_name("", name) + .await + .map_err(storage_error)? + .ok_or_else(|| Status::not_found("workspace not found"))?; + Ok(Scope { + name: name.into(), + workspace_id: Some(resource.object_id().into()), + }) +} + +fn global_scope(state: &ServerState, principal: &Principal) -> Result { + require_platform_admin(&state.admin_role, principal)?; + Ok(Scope { + name: String::new(), + workspace_id: None, + }) +} + +async fn template_scope( + state: &ServerState, + principal: &Principal, + selector: Option<&WorkspaceSelector>, +) -> Result { + let authz = authorize_workspace_selector( + &state.store, + &state.admin_role, + principal, + selector, + MinWorkspaceRole::Admin, + ) + .await?; + named_scope(state, &authz.workspace).await +} + +async fn member_scope( + state: &ServerState, + principal: &Principal, + name: &str, + role: Option, +) -> Result { + let authz = authorize_workspace( + &state.store, + &state.admin_role, + principal, + name, + MinWorkspaceRole::Admin, + ) + .await?; + if let Some(role) = role { + workspace::authorize_member_role(role, authz.grant)?; + } + named_scope(state, &authz.workspace).await +} + +fn resource_success( + resource: Option<&T>, +) -> Result { + let resource = resource.ok_or_else(uncertain)?; + Ok(Success::Resource { + id: resource.object_id().into(), + version: resource.get_resource_version(), + }) +} + +async fn restore_resource< + T: Message + Default + ObjectType + SetResourceVersion + GetResourceVersion, +>( + store: &Store, + success: Success, +) -> Result { + let Success::Resource { id, version } = success else { + return Err(replay_unavailable()); + }; + let resource: T = store + .get_message(&id) + .await + .map_err(storage_error)? + .ok_or_else(replay_unavailable)?; + if resource.get_resource_version() != version { + return Err(replay_unavailable()); + } + Ok(resource) +} + +macro_rules! resource_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path, $field:ident, $auth:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Self::Output) -> Result { + resource_success(response.$field.as_ref()) + } + async fn restore(store: &Store, success: Success) -> Result { + Ok($resp { + $field: Some(restore_resource(store, success).await?), + }) + } + } + }; +} + +macro_rules! deletion_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path, $auth:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Self::Output) -> Result { + Ok(Success::Deletion { + outcome: response.outcome, + }) + } + async fn restore(_store: &Store, success: Success) -> Result { + let Success::Deletion { outcome } = success else { + return Err(replay_unavailable()); + }; + Ok($resp { outcome }) + } + } + }; +} + +resource_mutation!( + CreateWorkspaceRequest, + CreateWorkspaceResponse, + "CreateWorkspace", + workspace::handle_create_workspace, + workspace, + async |_req: &CreateWorkspaceRequest, state: &ServerState, principal: &Principal| { + global_scope(state, principal) + } +); +deletion_mutation!( + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + "DeleteWorkspace", + workspace::handle_delete_workspace, + async |_req: &DeleteWorkspaceRequest, state: &ServerState, principal: &Principal| { + global_scope(state, principal) + } +); +resource_mutation!( + CreateSandboxTemplateRequest, + SandboxTemplateResponse, + "CreateSandboxTemplate", + sandbox::handle_create_sandbox_template, + template, + async |req: &CreateSandboxTemplateRequest, state: &ServerState, principal: &Principal| { + template_scope(state, principal, req.workspace_scope.as_ref()).await + } +); +deletion_mutation!( + DeleteSandboxTemplateRequest, + DeleteSandboxTemplateResponse, + "DeleteSandboxTemplate", + sandbox::handle_delete_sandbox_template, + async |req: &DeleteSandboxTemplateRequest, state: &ServerState, principal: &Principal| { + template_scope(state, principal, req.workspace_scope.as_ref()).await + } +); +resource_mutation!( + AddWorkspaceMemberRequest, + AddWorkspaceMemberResponse, + "AddWorkspaceMember", + workspace::handle_add_workspace_member, + member, + async |req: &AddWorkspaceMemberRequest, state: &ServerState, principal: &Principal| { + member_scope(state, principal, &req.workspace, Some(req.role)).await + } +); +deletion_mutation!( + RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, + "RemoveWorkspaceMember", + workspace::handle_remove_workspace_member, + async |req: &RemoveWorkspaceMemberRequest, state: &ServerState, principal: &Principal| { + member_scope(state, principal, &req.workspace, None).await + } +); + +#[cfg(test)] +mod tests; diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs new file mode 100644 index 0000000000..a75b5d7f59 --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -0,0 +1,825 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::grpc::test_support::{authed_request, test_server_state}; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::open_shell_server::OpenShell; +use openshell_core::proto::{ + SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, + WorkspaceRole, workspace_selector, +}; +use openshell_core::rpc_error::StatusExt; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; +use tonic::Code; + +fn create(name: &str) -> CreateWorkspaceRequest { + CreateWorkspaceRequest { + name: name.into(), + request_id: uuid::Uuid::new_v4().to_string(), + ..Default::default() + } +} + +fn reason(status: &Status) -> String { + assert!(status.get_error_details().retry_info().is_none()); + status + .get_error_details() + .error_info() + .unwrap_or_else(|| panic!("missing structured reason: {status:?}")) + .reason + .clone() +} + +async fn state_for(store: Store) -> Arc { + let store = Arc::new(store); + crate::ensure_default_workspace(&store).await.unwrap(); + let compute = crate::compute::new_test_runtime(store.clone()).await; + Arc::new(ServerState::new( + openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + store, + compute, + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + None, + )) +} + +#[test] +fn validates_bounded_nonzero_uuid_and_normalizes_case() { + for value in [ + "", + "not-a-uuid", + "00000000-0000-0000-0000-000000000000", + "550e8400e29b41d4a716446655440000", + "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + ] { + let status = validate_request_id(value).unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert_eq!( + status + .get_error_details() + .bad_request() + .unwrap() + .field_violations[0] + .field, + "request_id" + ); + } + assert_eq!( + validate_request_id("550E8400-E29B-41D4-A716-446655440000").unwrap(), + "550e8400-e29b-41d4-a716-446655440000" + ); +} + +#[test] +fn canonical_payload_ignores_map_order_and_id_but_preserves_presence() { + let mut first = create("canonical"); + first.labels = HashMap::from([("a".into(), "1".into()), ("b".into(), "2".into())]); + let mut second = create("canonical"); + second.labels = HashMap::from([("b".into(), "2".into()), ("a".into(), "1".into())]); + assert_eq!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap()); + second.labels.insert("b".into(), "3".into()); + assert_ne!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap()); + let mut template = CreateSandboxTemplateRequest::default(); + let absent = fingerprint(&template).unwrap(); + template.template = Some(SandboxWorkloadTemplate::default()); + assert_ne!(absent, fingerprint(&template).unwrap()); +} + +async fn exercise_backend(url: &str) { + let first = state_for(Store::connect(url).await.unwrap()).await; + let second = state_for(Store::connect(url).await.unwrap()).await; + let req = create(&format!( + "replay-{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + )); + let mut tasks = Vec::new(); + for i in 0..24 { + let state = if i % 2 == 0 { + first.clone() + } else { + second.clone() + }; + let req = req.clone(); + tasks.push(tokio::spawn(async move { + run(&state, authed_request(req)).await + })); + } + let mut successes = 0; + for task in tasks { + match task.await.unwrap() { + Ok(_) => successes += 1, + Err(status) => assert_eq!(reason(&status), "REQUEST_OUTCOME_UNCERTAIN"), + } + } + assert!(successes > 0); + let original = run(&first, authed_request(req.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!( + first + .store + .count_in_workspace(Workspace::object_type(), "") + .await + .unwrap(), + 2 + ); + drop(first); + drop(second); + let restarted = state_for(Store::connect(url).await.unwrap()).await; + let replay = run(&restarted, authed_request(req.clone())).await.unwrap(); + assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true"); + assert_eq!(original, replay.into_inner()); + let mut mismatch = req.clone(); + mismatch.labels.insert("changed".into(), "payload".into()); + assert_eq!( + reason(&run(&restarted, authed_request(mismatch)).await.unwrap_err()), + "REQUEST_ID_PAYLOAD_MISMATCH" + ); + + // An old deletion replay must not delete a same-name replacement. + let deletion = DeleteWorkspaceRequest { + name: req.name.clone(), + request_id: uuid::Uuid::new_v4().to_string(), + ..Default::default() + }; + run(&restarted, authed_request(deletion.clone())) + .await + .unwrap(); + let replacement = run(&restarted, authed_request(create(&req.name))) + .await + .unwrap() + .into_inner(); + run(&restarted, authed_request(deletion)).await.unwrap(); + assert_eq!( + restarted + .store + .get_message::(replacement.workspace.as_ref().unwrap().object_id()) + .await + .unwrap(), + replacement.workspace + ); + assert_eq!( + reason(&run(&restarted, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + exercise_expiry_and_uncertainty(&restarted).await; +} + +#[tokio::test] +async fn sqlite_concurrency_restart_name_reuse_expiry_and_uncertainty() { + let dir = tempfile::tempdir().unwrap(); + exercise_backend(&format!( + "sqlite://{}?mode=rwc", + dir.path().join("replay.db").display() + )) + .await; +} + +#[tokio::test] +#[ignore = "requires a disposable PostgreSQL database in OPENSHELL_REPLAY_TEST_DATABASE_URL"] +async fn postgres_concurrency_restart_name_reuse_expiry_and_uncertainty() { + let url = std::env::var("OPENSHELL_REPLAY_TEST_DATABASE_URL") + .expect("disposable PostgreSQL database URL"); + assert!(url.starts_with("postgres")); + let schema = format!("replay_{}", uuid::Uuid::new_v4().simple()); + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&pool) + .await + .unwrap(); + let mut scoped = url::Url::parse(&url).unwrap(); + scoped + .query_pairs_mut() + .append_pair("options", &format!("-csearch_path={schema}")); + exercise_backend(scoped.as_str()).await; + // Only the randomly named schema owned by this test is removed. + sqlx::query(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) + .execute(&pool) + .await + .unwrap(); +} + +async fn exercise_expiry_and_uncertainty(state: &Arc) { + let req = create("expired-result"); + run(state, authed_request(req.clone())).await.unwrap(); + let hash = fingerprint(&req).unwrap(); + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + let row = rows + .into_iter() + .find(|row| { + serde_json::from_slice::(&row.payload) + .unwrap() + .payload_hash + == hash + }) + .unwrap(); + let mut expired: Admission = serde_json::from_slice(&row.payload).unwrap(); + expired.completed_at_ms = Some(current_time_ms() - SUCCESS_TTL_MS); + state + .store + .put_if( + OBJECT_TYPE, + &row.id, + &row.name, + &row.workspace, + &serde_json::to_vec(&expired).unwrap(), + None, + WriteCondition::MatchResourceVersion(row.resource_version), + ) + .await + .unwrap(); + let expired_row = state + .store + .get(OBJECT_TYPE, &row.id) + .await + .unwrap() + .unwrap(); + // Expiry admits a fresh attempt, which sees the existing resource. The + // resulting error remains unresolved even if the original resource is removed. + assert_eq!( + run(state, authed_request(req.clone())) + .await + .unwrap_err() + .code(), + Code::AlreadyExists + ); + assert!( + !state + .store + .delete_if(OBJECT_TYPE, &expired_row.id, expired_row.resource_version) + .await + .unwrap() + ); + let pending = state + .store + .get_by_name(OBJECT_TYPE, &row.workspace, &row.name) + .await + .unwrap() + .unwrap(); + assert_ne!(pending.id, expired_row.id); + let mut admission: Admission = serde_json::from_slice(&pending.payload).unwrap(); + admission.completed_at_ms = Some(0); // Even an old timestamp cannot expire an unresolved record. + state + .store + .put_if( + OBJECT_TYPE, + &pending.id, + &pending.name, + &pending.workspace, + &serde_json::to_vec(&admission).unwrap(), + None, + WriteCondition::MatchResourceVersion(pending.resource_version), + ) + .await + .unwrap(); + state + .store + .delete_by_name(Workspace::object_type(), "", &req.name) + .await + .unwrap(); + assert_eq!( + reason(&run(state, authed_request(req)).await.unwrap_err()), + "REQUEST_OUTCOME_UNCERTAIN" + ); + assert!(!prune_expired(&state.store, &row.workspace).await.unwrap()); +} + +#[tokio::test] +async fn replays_reauthorize_membership_and_admin_grants() { + let mut state = test_server_state().await; + Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".into(); + let req = AddWorkspaceMemberRequest { + workspace: "default".into(), + principal_subject: "member".into(), + role: WorkspaceRole::Admin.into(), + request_id: uuid::Uuid::new_v4().to_string(), + }; + run(&state, authed_request(req.clone())).await.unwrap(); + let mut downgraded = authed_request(req.clone()); + let Principal::User(user) = downgraded.extensions_mut().get_mut::().unwrap() else { + unreachable!() + }; + user.identity.roles.clear(); + let caller = WorkspaceMember { + metadata: Some(ObjectMeta { + id: "caller".into(), + name: "dev-user".into(), + workspace: "default".into(), + ..Default::default() + }), + principal_subject: "dev-user".into(), + role: WorkspaceRole::Admin.into(), + }; + state.store.put_message(&caller).await.unwrap(); + assert_eq!( + run(&state, downgraded).await.unwrap_err().code(), + Code::PermissionDenied + ); + let remove = RemoveWorkspaceMemberRequest { + workspace: "default".into(), + principal_subject: "missing".into(), + allow_missing: true, + request_id: uuid::Uuid::new_v4().to_string(), + }; + let member_request = |req| { + let mut request = authed_request(req); + if let Principal::User(user) = request.extensions_mut().get_mut::().unwrap() { + user.identity.roles.clear(); + } + request + }; + run(&state, member_request(remove.clone())).await.unwrap(); + state + .store + .delete(WorkspaceMember::object_type(), "caller") + .await + .unwrap(); + assert_eq!( + run(&state, member_request(remove)) + .await + .unwrap_err() + .code(), + Code::PermissionDenied + ); +} + +#[tokio::test] +async fn template_replay_stores_only_reference_and_checks_original_version() { + let state = test_server_state().await; + // Delete is also routed through the public dispatch adapter. + let req = DeleteSandboxTemplateRequest { + name: "missing".into(), + workspace_scope: Some(workspace_selector("default")), + allow_missing: true, + request_id: uuid::Uuid::new_v4().to_string(), + }; + let service = crate::grpc::OpenShellService::new(state.clone()); + service + .delete_sandbox_template(authed_request(req.clone())) + .await + .unwrap(); + assert_eq!( + service + .delete_sandbox_template(authed_request(req)) + .await + .unwrap() + .metadata() + .get("openshell-replayed") + .unwrap(), + "true" + ); + let req = CreateSandboxTemplateRequest { + template: Some(SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + name: "template".into(), + annotations: HashMap::from([("private".into(), "sensitive-template-value".into())]), + ..Default::default() + }), + spec: Some(SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: "registry.example.com/agent:latest".into(), + ..Default::default() + }), + ..Default::default() + }), + }), + workspace_scope: Some(workspace_selector("default")), + request_id: uuid::Uuid::new_v4().to_string(), + }; + let created = run(&state, authed_request(req.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!( + created, + run(&state, authed_request(req.clone())) + .await + .unwrap() + .into_inner() + ); + for row in state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + { + assert!( + !String::from_utf8(row.payload) + .unwrap() + .contains("sensitive-template-value") + ); + } + let original = created.template.unwrap(); + state + .store + .update_message_cas::( + original.object_id(), + original.get_resource_version(), + |changed| { + changed.metadata.as_mut().unwrap().annotations.clear(); + }, + ) + .await + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[derive(Clone, PartialEq, Message)] +struct ControlledCreate { + #[prost(string, tag = "1")] + name: String, + #[prost(string, tag = "3")] + request_id: String, +} + +#[derive(Default)] +struct Control { + started: tokio::sync::Notify, + proceed: tokio::sync::Notify, + calls: AtomicUsize, + fail_after_effect: bool, +} + +#[tonic::async_trait] +impl Mutation for ControlledCreate { + type Output = CreateWorkspaceResponse; + const METHOD: &'static str = "CreateWorkspace"; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result { + global_scope(state, principal) + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + let control = request.extensions().get::>().unwrap().clone(); + control.calls.fetch_add(1, Ordering::SeqCst); + control.started.notify_one(); + control.proceed.notified().await; + let req = request.into_inner(); + let response = workspace::handle_create_workspace( + state, + Request::new(CreateWorkspaceRequest { + name: req.name, + request_id: req.request_id, + ..Default::default() + }), + ) + .await?; + if control.fail_after_effect { + return Err(Status::internal("simulated interruption after effect")); + } + Ok(response) + } + fn capture(response: &Self::Output) -> Result { + resource_success(response.workspace.as_ref()) + } + async fn restore(store: &Store, success: Success) -> Result { + Ok(CreateWorkspaceResponse { + workspace: Some(restore_resource(store, success).await?), + }) + } +} + +#[tokio::test] +async fn cancellation_keeps_owned_work_and_duplicate_cannot_take_over() { + let state = test_server_state().await; + let control = Arc::new(Control::default()); + let req = create("cancelled-client"); + let mut request = authed_request(ControlledCreate { + name: req.name.clone(), + request_id: req.request_id.clone(), + }); + request.extensions_mut().insert(control.clone()); + let worker_state = state.clone(); + let caller = tokio::spawn(async move { run(&worker_state, request).await }); + tokio::time::timeout(Duration::from_secs(5), control.started.notified()) + .await + .unwrap(); + caller.abort(); + assert_eq!( + reason(&run(&state, authed_request(req.clone())).await.unwrap_err()), + "REQUEST_OUTCOME_UNCERTAIN" + ); + control.proceed.notify_one(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if run(&state, authed_request(req.clone())).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(control.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn quota_fails_closed_but_replays_and_expired_success_cleanup_still_work() { + let state = test_server_state().await; + let req = create("quota-replay"); + run(&state, authed_request(req.clone())).await.unwrap(); + let original = state + .store + .list_by_type_after(OBJECT_TYPE, None, 1) + .await + .unwrap() + .remove(0); + let pending = Admission { + format_version: 1, + payload_hash: "unused".into(), + workspace_id: None, + success: None, + completed_at_ms: None, + }; + let payload = serde_json::to_vec(&pending).unwrap(); + for i in 1..MAX_ADMISSIONS_PER_CALLER - 1 { + let id = format!("quota-{i}"); + state + .store + .create_if_workspace_count_below( + OBJECT_TYPE, + &id, + &id, + &original.workspace, + &payload, + None, + u64::from(MAX_ADMISSIONS_PER_CALLER), + ) + .await + .unwrap() + .unwrap(); + } + let (left, right) = tokio::join!( + run(&state, authed_request(create("quota-race-a"))), + run(&state, authed_request(create("quota-race-b"))) + ); + assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1); + assert_eq!( + left.err().or_else(|| right.err()).unwrap().code(), + Code::ResourceExhausted + ); + let new_req = create("quota-new"); + assert_eq!( + run(&state, authed_request(new_req.clone())) + .await + .unwrap_err() + .code(), + Code::ResourceExhausted + ); + assert!( + state + .store + .get_message_by_name::("", &new_req.name) + .await + .unwrap() + .is_none() + ); + run(&state, authed_request(req)).await.unwrap(); + let mut expired: Admission = serde_json::from_slice(&original.payload).unwrap(); + expired.completed_at_ms = Some(0); + state + .store + .put_if( + OBJECT_TYPE, + &original.id, + &original.name, + &original.workspace, + &serde_json::to_vec(&expired).unwrap(), + None, + WriteCondition::MatchResourceVersion(original.resource_version), + ) + .await + .unwrap(); + run(&state, authed_request(new_req)).await.unwrap(); + assert_eq!( + state + .store + .count_in_workspace(OBJECT_TYPE, &original.workspace) + .await + .unwrap(), + u64::from(MAX_ADMISSIONS_PER_CALLER) + ); + assert!( + state + .store + .get(OBJECT_TYPE, "quota-1") + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn workspace_replacement_blocks_replay_without_reexecuting_member_removal() { + let state = test_server_state().await; + let workspace_req = create("workspace-reuse"); + run(&state, authed_request(workspace_req.clone())) + .await + .unwrap(); + let member = AddWorkspaceMemberRequest { + workspace: workspace_req.name.clone(), + principal_subject: "member".into(), + role: WorkspaceRole::User.into(), + ..Default::default() + }; + run(&state, authed_request(member.clone())).await.unwrap(); + let remove = RemoveWorkspaceMemberRequest { + workspace: workspace_req.name.clone(), + principal_subject: "member".into(), + request_id: uuid::Uuid::new_v4().to_string(), + ..Default::default() + }; + run(&state, authed_request(remove.clone())).await.unwrap(); + run( + &state, + authed_request(DeleteWorkspaceRequest { + name: workspace_req.name.clone(), + ..Default::default() + }), + ) + .await + .unwrap(); + run(&state, authed_request(create(&workspace_req.name))) + .await + .unwrap(); + let replacement = run(&state, authed_request(member)) + .await + .unwrap() + .into_inner() + .member + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(remove)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert!( + state + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[test] +fn replay_allowlist_remains_outside_interception_and_ids_have_reviewed_wire_tags() { + for (method, tag) in [ + ("CreateWorkspace", 3), + ("DeleteWorkspace", 3), + ("AddWorkspaceMember", 4), + ("RemoveWorkspaceMember", 4), + ("CreateSandboxTemplate", 4), + ("DeleteSandboxTemplate", 5), + ] { + assert!(!openshell_gateway_interceptors::routes::INTERCEPTABLE_METHODS.contains(&method)); + let descriptor = DESCRIPTORS + .get_message_by_name(&format!("openshell.v1.{method}Request")) + .unwrap(); + let field = descriptor.get_field_by_name("request_id").unwrap(); + assert_eq!(field.number(), tag); + assert_eq!(field.kind(), prost_reflect::Kind::String); + } +} + +#[tokio::test] +async fn unrelated_oidc_configuration_does_not_reset_mtls_admission_identity() { + let mut state = test_server_state().await; + let req = DeleteSandboxTemplateRequest { + name: "mtls-template".into(), + workspace_scope: Some(workspace_selector("default")), + allow_missing: true, + request_id: "550e8400-e29b-41d4-a716-446655440000".into(), + }; + let mtls_request = |req| { + let mut request = authed_request(req); + if let Principal::User(user) = request.extensions_mut().get_mut::().unwrap() { + user.identity.provider = IdentityProvider::Mtls; + } + request + }; + run(&state, mtls_request(req.clone())).await.unwrap(); + let row = state + .store + .list_by_type_after(OBJECT_TYPE, None, 1) + .await + .unwrap() + .remove(0); + assert_eq!( + row.workspace, + "_mutation/63247cf7f072797edfb9ca3c4290683b41ccaaae23712042f315de35a54067ce" + ); + assert_eq!( + row.name, + "ecfb83c89c4be0bd1b67aee42c7049243af89db2df01d762b880e155694ca028" + ); + let template = SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: "mtls-replacement".into(), + name: req.name.clone(), + workspace: "default".into(), + ..Default::default() + }), + ..Default::default() + }; + state.store.put_message(&template).await.unwrap(); + Arc::get_mut(&mut state).unwrap().config.oidc = Some(openshell_core::OidcConfig { + issuer: "https://new.example.com".into(), + dangerously_allow_insecure_http: false, + jwks_allowed_origins: Vec::new(), + audience: "openshell-cli".into(), + jwks_ttl_secs: 300, + roles_claim: String::new(), + admin_role: String::new(), + user_role: String::new(), + scopes_claim: String::new(), + }); + run(&state, mtls_request(req.clone())).await.unwrap(); + assert!( + state + .store + .get_message::(template.object_id()) + .await + .unwrap() + .is_some() + ); + // Same textual subject/UUID from a different provider is a separate caller. + run(&state, authed_request(req)).await.unwrap(); + assert!( + state + .store + .get_message::(template.object_id()) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn restart_after_effect_without_success_persistence_never_reexecutes() { + let dir = tempfile::tempdir().unwrap(); + let url = format!( + "sqlite://{}?mode=rwc", + dir.path().join("interrupted.db").display() + ); + let state = state_for(Store::connect(&url).await.unwrap()).await; + let req = create("interrupted-effect"); + let control = Arc::new(Control { + fail_after_effect: true, + ..Default::default() + }); + control.proceed.notify_one(); + let mut request = authed_request(ControlledCreate { + name: req.name.clone(), + request_id: req.request_id.clone(), + }); + request.extensions_mut().insert(control.clone()); + assert_eq!( + run(&state, request).await.unwrap_err().code(), + Code::Internal + ); + let original: Workspace = state + .store + .get_message_by_name("", &req.name) + .await + .unwrap() + .unwrap(); + drop(state); + let restarted = state_for(Store::connect(&url).await.unwrap()).await; + assert_eq!( + reason( + &run(&restarted, authed_request(req.clone())) + .await + .unwrap_err() + ), + "REQUEST_OUTCOME_UNCERTAIN" + ); + assert_eq!(control.calls.load(Ordering::SeqCst), 1); + assert_eq!( + restarted + .store + .get_message_by_name::("", &req.name) + .await + .unwrap() + .unwrap(), + original + ); +} diff --git a/crates/openshell-server/src/grpc/mutation_tests.rs b/crates/openshell-server/src/grpc/mutation_tests.rs index 7cb14db25c..e0dfcd7584 100644 --- a/crates/openshell-server/src/grpc/mutation_tests.rs +++ b/crates/openshell-server/src/grpc/mutation_tests.rs @@ -169,6 +169,7 @@ async fn allow_missing_does_not_hide_missing_parents_or_invalid_requests() { let err = workspace::handle_remove_workspace_member( &state, authed_request(RemoveWorkspaceMemberRequest { + request_id: String::new(), workspace: "missing-parent".into(), principal_subject: "missing".into(), allow_missing: true, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 21e2b3c18c..d5033c388a 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -6730,6 +6730,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "alpha".to_string(), labels: HashMap::new(), }), @@ -13438,6 +13439,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 429a756c7c..05e525b5c0 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -4921,6 +4921,7 @@ mod tests { let created = handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template("gpu-kata")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -4977,6 +4978,7 @@ mod tests { let deleted = handle_delete_sandbox_template( &state, authed_request(DeleteSandboxTemplateRequest { + request_id: String::new(), allow_missing: false, name: "gpu-kata".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -5019,6 +5021,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(gpu), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5037,6 +5040,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(cpu), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5073,6 +5077,7 @@ mod tests { let err = handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template(" gpu-kata ")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5111,6 +5116,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), @@ -5124,6 +5130,7 @@ mod tests { let err = handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template), workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), @@ -5161,6 +5168,7 @@ mod tests { let err = handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5300,6 +5308,7 @@ mod tests { let err = handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template("overflow")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5331,6 +5340,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template(&format!("overflow-{index}"))), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5431,6 +5441,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template("gpu-kata")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5535,6 +5546,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5586,6 +5598,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(template), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -5661,6 +5674,7 @@ mod tests { handle_create_sandbox_template( &state, authed_request(CreateSandboxTemplateRequest { + request_id: String::new(), template: Some(test_workload_template("gpu-kata")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -6497,6 +6511,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 2d142cd082..328c693004 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -704,6 +704,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 729dfd79ee..98a13590a4 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -454,6 +454,21 @@ pub(super) async fn handle_delete_workspace( })) } +pub(super) fn authorize_member_role(role: i32, grant: AuthGrant) -> Result<(), Status> { + let role = WorkspaceRole::try_from(role).unwrap_or(WorkspaceRole::Unspecified); + if role == WorkspaceRole::Unspecified { + return Err(Status::invalid_argument( + "role must be USER or ADMIN, not UNSPECIFIED", + )); + } + if role == WorkspaceRole::Admin && grant != AuthGrant::PlatformAdmin { + return Err(Status::permission_denied( + "only platform admins can assign the workspace admin role", + )); + } + Ok(()) +} + pub(super) async fn handle_add_workspace_member( state: &Arc, request: Request, @@ -477,17 +492,7 @@ pub(super) async fn handle_add_workspace_member( return Err(Status::invalid_argument("principal_subject is required")); } - let role = WorkspaceRole::try_from(req.role).unwrap_or(WorkspaceRole::Unspecified); - if role == WorkspaceRole::Unspecified { - return Err(Status::invalid_argument( - "role must be USER or ADMIN, not UNSPECIFIED", - )); - } - if role == WorkspaceRole::Admin && authz.grant != AuthGrant::PlatformAdmin { - return Err(Status::permission_denied( - "only platform admins can assign the workspace admin role", - )); - } + authorize_member_role(req.role, authz.grant)?; let count = state .store @@ -658,6 +663,7 @@ mod tests { let resp = handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "new-ws".to_string(), labels: HashMap::from([("env".to_string(), "test".to_string())]), }), @@ -686,6 +692,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "dup-ws".to_string(), labels: HashMap::new(), }), @@ -696,6 +703,7 @@ mod tests { let err = handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "dup-ws".to_string(), labels: HashMap::new(), }), @@ -713,6 +721,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "fetch-me".to_string(), labels: HashMap::from([("team".to_string(), "infra".to_string())]), }), @@ -786,6 +795,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "ephemeral".to_string(), labels: HashMap::new(), }), @@ -811,6 +821,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "ephemeral".to_string(), }), @@ -833,6 +844,7 @@ mod tests { let resp = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "ephemeral".to_string(), }), @@ -853,6 +865,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "templated".to_string(), labels: HashMap::new(), }), @@ -878,6 +891,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "templated".to_string(), }), @@ -904,6 +918,7 @@ mod tests { let resp = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "templated".to_string(), }), @@ -924,6 +939,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "sessioned".to_string(), labels: HashMap::new(), }), @@ -952,6 +968,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "sessioned".to_string(), }), @@ -973,6 +990,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "profiles-ws".to_string(), labels: HashMap::new(), }), @@ -998,6 +1016,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "profiles-ws".to_string(), }), @@ -1024,6 +1043,7 @@ mod tests { let resp = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "profiles-ws".to_string(), }), @@ -1044,6 +1064,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "default".to_string(), }), @@ -1060,6 +1081,7 @@ mod tests { let resp = handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "default".to_string(), principal_subject: "alice@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -1076,6 +1098,7 @@ mod tests { handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "default".to_string(), principal_subject: "bob@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -1106,6 +1129,7 @@ mod tests { handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "default".to_string(), principal_subject: "charlie@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -1117,6 +1141,7 @@ mod tests { let resp = handle_remove_workspace_member( &state, authed_request(RemoveWorkspaceMemberRequest { + request_id: String::new(), allow_missing: false, workspace: "default".to_string(), principal_subject: "charlie@example.com".to_string(), @@ -1152,6 +1177,7 @@ mod tests { handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "default".to_string(), principal_subject: "dave@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -1163,6 +1189,7 @@ mod tests { let err = handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "default".to_string(), principal_subject: "dave@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -1181,6 +1208,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "cleanup-test".to_string(), labels: HashMap::new(), }), @@ -1191,6 +1219,7 @@ mod tests { handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "cleanup-test".to_string(), principal_subject: "alice@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -1202,6 +1231,7 @@ mod tests { handle_add_workspace_member( &state, authed_request(AddWorkspaceMemberRequest { + request_id: String::new(), workspace: "cleanup-test".to_string(), principal_subject: "bob@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -1226,6 +1256,7 @@ mod tests { let resp = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "cleanup-test".to_string(), }), @@ -1294,6 +1325,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "term-test".to_string(), labels: HashMap::new(), }), @@ -1319,6 +1351,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "term-test".to_string(), }), @@ -1347,6 +1380,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "dying-ws".to_string(), labels: HashMap::new(), }), @@ -1373,6 +1407,7 @@ mod tests { let _ = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "dying-ws".to_string(), }), @@ -1393,6 +1428,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "idempotent-ws".to_string(), labels: HashMap::new(), }), @@ -1419,6 +1455,7 @@ mod tests { let _ = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "idempotent-ws".to_string(), }), @@ -1436,6 +1473,7 @@ mod tests { let resp = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "idempotent-ws".to_string(), }), @@ -1456,6 +1494,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "cleanup-retry".to_string(), labels: HashMap::new(), }), @@ -1466,6 +1505,7 @@ mod tests { let err = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "cleanup-retry".to_string(), }), @@ -1485,6 +1525,7 @@ mod tests { let retry = handle_delete_workspace( &state, Request::new(DeleteWorkspaceRequest { + request_id: String::new(), allow_missing: false, name: "cleanup-retry".to_string(), }), @@ -1516,6 +1557,7 @@ mod tests { handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "labeled-ws".to_string(), labels: labels.clone(), }), diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index e15eed5c4d..a6ebf3f9ab 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,7 +118,7 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "d323fdd0c989049950ed3bf9cc2cbae9f05e1ae83b13fa2a65a2f5db1ebab95f"; + "777946305230845c6043e0fde992d2a16ac323ee9fce3e1a0c29b6652adbe1d6"; const DURABLE_SCHEMA_SHA256: &str = "65066c0b0eef57a4c708f20fcbbb8e8f47376da9f4bf73dfc3bca0b3df174ba8"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = diff --git a/docs/reference/api-errors.mdx b/docs/reference/api-errors.mdx index 9f77169ab0..5266913bbc 100644 --- a/docs/reference/api-errors.mdx +++ b/docs/reference/api-errors.mdx @@ -31,6 +31,9 @@ Recognized gateway reasons include the following. | `INVALID_ARGUMENT` | `INVALID_ARGUMENT` | Correct the fields listed in `BadRequest`. | | `RESOURCE_VERSION_CONFLICT` | `ABORTED` | Read the resource again and construct a new conditional write. `metadata.recovery` is `REFRESH_STATE`; `current_resource_version` is included when known. | | `PROFILE_SOURCE_UNAVAILABLE` | `UNAVAILABLE` | Retry a profile snapshot read after at least the supplied delay. | +| `REQUEST_ID_PAYLOAD_MISMATCH` | `FAILED_PRECONDITION` | Keep the original payload for that request ID. Inspect the original operation before submitting a different one. | +| `REQUEST_OUTCOME_UNCERTAIN` | `FAILED_PRECONDITION` | An attempt is admitted but has no confirmed replayable success. Observe resource state and reconcile effects. Do not switch to a new ID to bypass the admission. | +| `REQUEST_REPLAY_UNAVAILABLE` | `FAILED_PRECONDITION` | The original workspace or created resource no longer exists at its recorded version. The gateway does not execute the request again. | ## Status and retry guidance @@ -85,6 +88,60 @@ Go and TypeScript add typed detail fields to their existing error types. No SDK automatically retries a mutation as a result of decoding these details. +## Durable request admission + +Six unary RPCs accept an optional `request_id` in their protobuf requests. + +| Resource | RPCs | +|---|---| +| Workspace | `CreateWorkspace`, `DeleteWorkspace` | +| Workspace member | `AddWorkspaceMember`, `RemoveWorkspaceMember` | +| Sandbox template | `CreateSandboxTemplate`, `DeleteSandboxTemplate` | + +Use a nonzero, hyphenated UUID of exactly 36 characters. Uppercase and lowercase +forms identify the same request. An empty ID preserves the existing behavior +without deduplication. This ID is separate from transport correlation metadata. +The gateway scopes it to the authenticated identity provider, OIDC issuer, +subject, RPC method, and requested workspace. Use the same identity, scope, ID, +and payload when checking an admitted attempt. Map ordering does not affect the +payload fingerprint; changed values or protobuf message presence do. + +The gateway durably admits one owner before executing the mutation. Concurrent +duplicates either replay its confirmed success or return +`REQUEST_OUTCOME_UNCERTAIN`. Disconnecting or cancelling the client does not stop +admitted work. An error, interrupted process, or failed success-record write +leaves the admission unresolved. No gateway replica takes over that admission, +even after restart or after 24 hours. Status codes alone do not prove that the +attempt had no effects. + +Successful results have a 24-hour replay window, measured from durable completion. +A successful replay includes `openshell-replayed: true` response metadata and +does not extend the window. After it expires, the same ID can admit a new +execution. Do not retry an old operation after that window without reconciling +its effects. Unresolved admissions never expire. + +Every replay checks current authorization, including membership and permission +to assign workspace administrators. Create replays load the original resource +by its UUID and require its recorded resource version. Deletion or modification +of that resource makes replay unavailable, even within 24 hours. A replacement +workspace also makes workspace-scoped replay unavailable. Delete replays return +the recorded outcome without deleting a same-name replacement. + +Replay rows contain fingerprints, resource references, and deletion outcomes, +not copies of template contents or credential-bearing responses. Each caller can +hold at most 1,000 durable admissions. At capacity, the gateway removes expired +successes; if no space remains, it rejects new admissions with +`RESOURCE_EXHAUSTED`. Existing records remain protected. Unresolved records +require operator investigation; there is no automatic reset or takeover API. +The gateway also limits detached admission workers to 64 per process and these +request messages to 4 MiB. + +Supply IDs through generated/raw RPC clients. Curated SDK request-ID helpers and +automatic retry policies are not part of this contract yet. Other mutations, +including sandbox creation, provider writes, exec, SSH sessions, and rootfs +staging, do not gain deduplication from this feature. Check gateway compatibility +before relying on IDs; older protobuf servers can silently ignore unknown fields. + ## Deletion outcomes Delete, membership-removal, and SSH-revocation RPCs return a typed outcome. diff --git a/e2e/python/test_workspace_api.py b/e2e/python/test_workspace_api.py index 1fb33123eb..ab1b8f609a 100644 --- a/e2e/python/test_workspace_api.py +++ b/e2e/python/test_workspace_api.py @@ -10,6 +10,8 @@ import grpc import pytest +from openshell._proto import openshell_pb2 + if TYPE_CHECKING: from openshell import WorkspaceClient @@ -74,3 +76,41 @@ def test_workspace_get_nonexistent_raises_not_found( with pytest.raises(grpc.RpcError) as exc_info: workspace_client.get(f"no-such-ws-{uuid.uuid4().hex[:8]}") assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_workspace_request_id_replays_without_deleting_replacement( + workspace_client: WorkspaceClient, +) -> None: + name = f"ws-replay-{uuid.uuid4().hex[:8]}" + # Exercise the generated wire fields before curated request-ID helpers land. + stub = workspace_client._stub + create = openshell_pb2.CreateWorkspaceRequest( + name=name, request_id=str(uuid.uuid4()) + ) + delete = openshell_pb2.DeleteWorkspaceRequest( + name=name, request_id=str(uuid.uuid4()) + ) + try: + original = stub.CreateWorkspace(create, timeout=20) + replay, call = stub.CreateWorkspace.with_call(create, timeout=20) + assert original == replay + assert dict(call.initial_metadata())["openshell-replayed"] == "true" + + mismatch = openshell_pb2.CreateWorkspaceRequest( + name=name, request_id=create.request_id, labels={"changed": "true"} + ) + with pytest.raises(grpc.RpcError) as exc_info: + stub.CreateWorkspace(mismatch, timeout=20) + assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION + + removed = stub.DeleteWorkspace(delete, timeout=20) + replacement = stub.CreateWorkspace( + openshell_pb2.CreateWorkspaceRequest(name=name), timeout=20 + ) + assert replacement.workspace.metadata.id != original.workspace.metadata.id + assert stub.DeleteWorkspace(delete, timeout=20) == removed + fetched = stub.GetWorkspace(openshell_pb2.GetWorkspaceRequest(name=name), timeout=20) + assert fetched.workspace.metadata.id == replacement.workspace.metadata.id + finally: + with contextlib.suppress(Exception): + workspace_client.delete(name) diff --git a/proto/openshell.proto b/proto/openshell.proto index a349e1db8a..3319bc61a8 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1169,6 +1169,9 @@ message CreateSandboxTemplateRequest { SandboxWorkloadTemplate template = 1; // Explicit workspace for the template. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } message GetSandboxTemplateRequest { @@ -1203,6 +1206,8 @@ message DeleteSandboxTemplateRequest { // Succeed with ALREADY_ABSENT if the target is missing. Authorization and // parent-workspace checks still apply. bool allow_missing = 4; + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + string request_id = 5; } message SandboxTemplateResponse { @@ -3057,6 +3062,8 @@ message CreateWorkspaceRequest { string name = 1; // Optional labels for the workspace (key-value metadata). map labels = 2; + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + string request_id = 3; } // Create workspace response. @@ -3099,6 +3106,8 @@ message DeleteWorkspaceRequest { // Workspace name (canonical lookup key). string name = 1; bool allow_missing = 2; + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + string request_id = 3; } // Delete workspace response. @@ -3148,6 +3157,8 @@ message AddWorkspaceMemberRequest { string principal_subject = 2; // Role to assign. WorkspaceRole role = 3; + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + string request_id = 4; } // Add workspace member response. @@ -3162,6 +3173,8 @@ message RemoveWorkspaceMemberRequest { // OIDC subject claim identifying the principal to remove. string principal_subject = 2; bool allow_missing = 3; + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + string request_id = 4; } // Remove workspace member response. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 935ceaac47..b00ddc8e60 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -2756,8 +2756,11 @@ type CreateSandboxTemplateRequest struct { Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` // Explicit workspace for the template. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxTemplateRequest) Reset() { @@ -2804,6 +2807,13 @@ func (x *CreateSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *CreateSandboxTemplateRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type GetSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -2938,7 +2948,9 @@ type DeleteSandboxTemplateRequest struct { WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` // Succeed with ALREADY_ABSENT if the target is missing. Authorization and // parent-workspace checks still apply. - AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2994,6 +3006,13 @@ func (x *DeleteSandboxTemplateRequest) GetAllowMissing() bool { return false } +func (x *DeleteSandboxTemplateRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type SandboxTemplateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` @@ -13716,7 +13735,9 @@ type CreateWorkspaceRequest struct { // Workspace name. Must be a valid DNS-1123 label. Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Optional labels for the workspace (key-value metadata). - Labels map[string]string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Labels map[string]string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -13765,6 +13786,13 @@ func (x *CreateWorkspaceRequest) GetLabels() map[string]string { return nil } +func (x *CreateWorkspaceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Create workspace response. type CreateWorkspaceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14025,8 +14053,10 @@ func (x *ListWorkspacesResponse) GetNextPageToken() string { type DeleteWorkspaceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Workspace name (canonical lookup key). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - AllowMissing bool `protobuf:"varint,2,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + AllowMissing bool `protobuf:"varint,2,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -14075,6 +14105,13 @@ func (x *DeleteWorkspaceRequest) GetAllowMissing() bool { return false } +func (x *DeleteWorkspaceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete workspace response. type DeleteWorkspaceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14191,7 +14228,9 @@ type AddWorkspaceMemberRequest struct { // OIDC subject claim identifying the principal. PrincipalSubject string `protobuf:"bytes,2,opt,name=principal_subject,json=principalSubject,proto3" json:"principal_subject,omitempty"` // Role to assign. - Role WorkspaceRole `protobuf:"varint,3,opt,name=role,proto3,enum=openshell.v1.WorkspaceRole" json:"role,omitempty"` + Role WorkspaceRole `protobuf:"varint,3,opt,name=role,proto3,enum=openshell.v1.WorkspaceRole" json:"role,omitempty"` + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -14247,6 +14286,13 @@ func (x *AddWorkspaceMemberRequest) GetRole() WorkspaceRole { return WorkspaceRole_WORKSPACE_ROLE_UNSPECIFIED } +func (x *AddWorkspaceMemberRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Add workspace member response. type AddWorkspaceMemberResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14300,8 +14346,10 @@ type RemoveWorkspaceMemberRequest struct { // OIDC subject claim identifying the principal to remove. PrincipalSubject string `protobuf:"bytes,2,opt,name=principal_subject,json=principalSubject,proto3" json:"principal_subject,omitempty"` AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID. Same ID and payload replay success for 24 hours. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RemoveWorkspaceMemberRequest) Reset() { @@ -14355,6 +14403,13 @@ func (x *RemoveWorkspaceMemberRequest) GetAllowMissing() bool { return false } +func (x *RemoveWorkspaceMemberRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Remove workspace member response. type RemoveWorkspaceMemberResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -15052,10 +15107,12 @@ const file_openshell_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x05\x10\x06R\tworkspace\"\xc6\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x05\x10\x06R\tworkspace\"\xe5\x01\n" + "\x1cCreateSandboxTemplateRequest\x12A\n" + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x94\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\x94\x01\n" + "\x19GetSandboxTemplateRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xfb\x01\n" + @@ -15064,11 +15121,13 @@ const file_openshell_proto_rawDesc = "" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12%\n" + "\x0elabel_selector\x18\x05 \x01(\tR\rlabelSelector\x12R\n" + - "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\xbc\x01\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\xdb\x01\n" + "\x1cDeleteSandboxTemplateRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"\\\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\\\n" + "\x17SandboxTemplateResponse\x12A\n" + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\"\x8b\x01\n" + "\x1cListSandboxTemplatesResponse\x12C\n" + @@ -15879,10 +15938,12 @@ const file_openshell_proto_rawDesc = "" + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x19\n" + "\bchunk_id\x18\x04 \x01(\tR\achunkIdJ\x04\b\x01\x10\x02R\ftimestamp_ms\"T\n" + "\x17GetDraftHistoryResponse\x129\n" + - "\aentries\x18\x01 \x03(\v2\x1f.openshell.v1.DraftHistoryEntryR\aentries\"\xb1\x01\n" + + "\aentries\x18\x01 \x03(\v2\x1f.openshell.v1.DraftHistoryEntryR\aentries\"\xd0\x01\n" + "\x16CreateWorkspaceRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12H\n" + - "\x06labels\x18\x02 \x03(\v20.openshell.v1.CreateWorkspaceRequest.LabelsEntryR\x06labels\x1a9\n" + + "\x06labels\x18\x02 \x03(\v20.openshell.v1.CreateWorkspaceRequest.LabelsEntryR\x06labels\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Z\n" + @@ -15901,26 +15962,32 @@ const file_openshell_proto_rawDesc = "" + "\n" + "workspaces\x18\x01 \x03(\v2!.openshell.datamodel.v1.WorkspaceR\n" + "workspaces\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"Q\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"p\n" + "\x16DeleteWorkspaceRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + - "\rallow_missing\x18\x02 \x01(\bR\fallowMissing\"a\n" + + "\rallow_missing\x18\x02 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\"a\n" + "\x17DeleteWorkspaceResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xaf\x01\n" + "\x0fWorkspaceMember\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12+\n" + "\x11principal_subject\x18\x02 \x01(\tR\x10principalSubject\x12/\n" + - "\x04role\x18\x03 \x01(\x0e2\x1b.openshell.v1.WorkspaceRoleR\x04role\"\x97\x01\n" + + "\x04role\x18\x03 \x01(\x0e2\x1b.openshell.v1.WorkspaceRoleR\x04role\"\xb6\x01\n" + "\x19AddWorkspaceMemberRequest\x12\x1c\n" + "\tworkspace\x18\x01 \x01(\tR\tworkspace\x12+\n" + "\x11principal_subject\x18\x02 \x01(\tR\x10principalSubject\x12/\n" + - "\x04role\x18\x03 \x01(\x0e2\x1b.openshell.v1.WorkspaceRoleR\x04role\"S\n" + + "\x04role\x18\x03 \x01(\x0e2\x1b.openshell.v1.WorkspaceRoleR\x04role\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\"S\n" + "\x1aAddWorkspaceMemberResponse\x125\n" + - "\x06member\x18\x01 \x01(\v2\x1d.openshell.v1.WorkspaceMemberR\x06member\"\x8e\x01\n" + + "\x06member\x18\x01 \x01(\v2\x1d.openshell.v1.WorkspaceMemberR\x06member\"\xad\x01\n" + "\x1cRemoveWorkspaceMemberRequest\x12\x1c\n" + "\tworkspace\x18\x01 \x01(\tR\tworkspace\x12+\n" + "\x11principal_subject\x18\x02 \x01(\tR\x10principalSubject\x12#\n" + - "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\"g\n" + + "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\"g\n" + "\x1dRemoveWorkspaceMemberResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\aremoved\"w\n" + "\x1bListWorkspaceMembersRequest\x12\x1c\n" + From 8c8a05a098be41938688c3cfcf31641f8f8f5877 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 15 Sep 2026 10:48:55 -0700 Subject: [PATCH 2/3] feat(api): extend mutation replay through gateway interceptors (#3323) Add typed durable replay receipts for 24 ordinary unary mutations, protect sensitive payload fingerprints, and revalidate intercepted retries without repeating post-commit observation. Part of #3051 (phase 3b). Signed-off-by: Mrunal Patel --- architecture/gateway.md | 35 +- crates/openshell-cli/src/commands/provider.rs | 16 + crates/openshell-cli/src/run.rs | 10 + .../src/proto_json.rs | 3 + .../src/runtime.rs | 3 + crates/openshell-sdk/src/client.rs | 8 + crates/openshell-server/src/grpc/mod.rs | 50 +- .../src/grpc/mutation_replay.rs | 145 ++- .../src/grpc/mutation_replay/ordinary.rs | 908 +++++++++++++++++ .../grpc/mutation_replay/ordinary/tests.rs | 954 ++++++++++++++++++ .../src/grpc/mutation_replay/tests.rs | 10 +- .../src/grpc/mutation_tests.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 48 + crates/openshell-server/src/grpc/provider.rs | 125 ++- crates/openshell-server/src/grpc/sandbox.rs | 39 + crates/openshell-server/src/grpc/service.rs | 11 + crates/openshell-server/src/multiplex.rs | 205 +++- crates/openshell-server/src/storage_proto.rs | 2 +- crates/openshell-tui/src/lib.rs | 8 + docs/reference/api-errors.mdx | 62 +- e2e/python/test_sandbox_api.py | 110 +- proto/openshell.proto | 72 ++ sdk/go/proto/openshellv1/openshell.pb.go | 466 +++++++-- 23 files changed, 3119 insertions(+), 172 deletions(-) create mode 100644 crates/openshell-server/src/grpc/mutation_replay/ordinary.rs create mode 100644 crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs diff --git a/architecture/gateway.md b/architecture/gateway.md index bfc1df66ba..673e096756 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -62,8 +62,8 @@ transport status, metadata, and unknown details alongside decoded fields. SDK deletion waits recognize missing-resource status through typed error wrappers without suppressing other failures. -Workspace lifecycle, membership, and sandbox-template mutations explicitly opt -into durable request admission when the client supplies a UUID. Typed adapters +Ordinary user-callable unary mutations explicitly opt into durable request +admission when the client supplies a UUID. Typed adapters check current authorization before looking up a caller/method/workspace-scoped key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps. An atomic, quota-checked insert chooses one executor; owned execution survives @@ -73,10 +73,25 @@ interruption leave permanent unresolved claims, never stealable leases. Admission rows live outside user workspace namespaces and are bounded per caller. Successes expire after 24 hours; cleanup uses the unique admission incarnation and version so an old cleaner cannot delete a new attempt. Replay stores only -resource UUID/version references or deletion outcomes. It checks the original -workspace identity and current authorization, and never substitutes a same-name -resource. Intercepted mutations and credential capabilities require separate -adapters; this mechanism does not replay streams or repeat interceptor observers. +resource references and reviewed public scalar/diagnostic receipts, never +credential-bearing response snapshots. It checks original identities and current +authorization and never substitutes a same-name resource. Sandbox responses are +live projections of the original UUID; normal status reconciliation does not +invalidate replay. Refresh status additionally requires the original grant epoch +and no deletion timestamp, including a timestamp at the Unix epoch. +Other resource projections retain exact-version guards. Terminal delete receipts +do not require the deleted target or parent to remain present. + +Sandbox, service, provider/profile, and policy/config adapters use keyed payload +fingerprints derived from existing gateway JWT or primary TLS private material. +Replicas must share that material; missing keys or key changes fail closed without +changing admission identity. Workspace/template adapters retain their original +format. Intercepted requests carry the original decoded payload only in a private +in-memory extension. Replay reauthorizes original and current effective scopes, +requires the same effective payload, and reruns current interceptor validation. +Interceptors cannot mutate the request UUID. Server-marked replay suppresses +post-commit observation, which remains best-effort rather than an outbox. +Credential capabilities and streaming execution require separate contracts. The gateway listens on one service port and multiplexes gRPC and HTTP traffic. The default local single-user deployment mode is mTLS user authentication: @@ -407,8 +422,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests failures remain errors. Already-revoked sessions complete without another write after current authorization. The removed response booleans are reserved by name and number; this coordinated pre-1.0 API change does not alter durable schemas. -The outcome alone does not provide request deduplication. The six opted-in -workspace/template methods require a request UUID for the admission contract. +The outcome alone does not provide request deduplication. Opted-in unary methods +require a request UUID for the admission contract. | Dual-purpose encoded root | Current decision | |---|---| @@ -461,8 +476,8 @@ advisor drafts without creating resource-specific tables. Mutation admission uses a private, version-tagged JSON envelope in the same object store. Its identity namespace stays stable across format changes, and an -unknown format fails closed. It contains no public response payloads and is not -part of the protobuf storage closure. +unknown format fails closed. It contains explicit typed receipts, not arbitrary +public response payloads, and is not part of the protobuf storage closure. Each sandbox policy revision stores the complete provenance annotation map supplied with that update. The revision payload is the authoritative immutable diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 414b624c92..772a7e8139 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -109,6 +109,7 @@ pub async fn sandbox_provider_attach( let response = match client .attach_sandbox_provider(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, @@ -165,6 +166,7 @@ pub async fn sandbox_provider_detach( let response = match client .detach_sandbox_provider(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, @@ -477,6 +479,7 @@ async fn auto_create_provider( if let Some(exact_name) = preferred_name { // Explicit name: create with exactly that name, no retries. let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -525,6 +528,7 @@ async fn auto_create_provider( }; let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -676,6 +680,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure( ) -> Result<()> { match client .delete_provider(DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name: provider_name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1107,6 +1112,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> let response = client .create_provider(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1153,6 +1159,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if let Err(configure_err) = client .configure_provider_refresh(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: provider_name.clone(), credential_key: adc_credential_key.clone(), strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, @@ -1178,6 +1185,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if let Err(rotate_err) = client .rotate_provider_credential(RotateProviderCredentialRequest { + request_id: String::new(), provider: provider_name.clone(), credential_key: adc_credential_key, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1601,6 +1609,7 @@ pub async fn provider_profile_import( if !items.is_empty() { let response = client .import_provider_profiles(ImportProviderProfilesRequest { + request_id: String::new(), profiles: items, workspace: workspace.to_string(), }) @@ -1650,6 +1659,7 @@ pub async fn provider_profile_update( .map_or(0, |profile| profile.resource_version); let response = client .update_provider_profiles(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(item), expected_resource_version, id: id.to_string(), @@ -1714,6 +1724,7 @@ pub async fn provider_profile_delete( for id in ids { let response = match client .delete_provider_profile(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: true, id: id.clone(), workspace: workspace.to_string(), @@ -1826,6 +1837,7 @@ pub async fn provider_refresh_config( let mut client = grpc_client(server, tls).await?; let status = client .configure_provider_refresh(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: input.name.to_string(), credential_key: input.credential_key.to_string(), strategy: strategy as i32, @@ -1863,6 +1875,7 @@ pub async fn provider_rotate( let mut client = grpc_client(server, tls).await?; let status = client .rotate_provider_credential(RotateProviderCredentialRequest { + request_id: String::new(), provider: name.to_string(), credential_key: credential_key.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1900,6 +1913,7 @@ pub async fn provider_refresh_delete( let mut client = grpc_client(server, tls).await?; let response = client .delete_provider_refresh(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: true, provider: name.to_string(), credential_key: credential_key.to_string(), @@ -2337,6 +2351,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { let response = client .update_provider(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2399,6 +2414,7 @@ pub async fn provider_delete( for name in names { let response = match client .delete_provider(DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index dd3d1edc5d..ffd0fa2206 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -642,6 +642,7 @@ pub async fn sandbox_create( )]) }; let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { resource_requirements, environment: if template.is_none() { @@ -3302,6 +3303,7 @@ pub async fn sandbox_delete( let response = match client .delete_sandbox(DeleteSandboxRequest { + request_id: String::new(), allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -3362,6 +3364,7 @@ pub async fn sandbox_stop( let mut client = grpc_client(server, tls).await?; let sandbox = client .stop_sandbox(StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) @@ -3385,6 +3388,7 @@ pub async fn sandbox_start( let mut client = grpc_client(server, tls).await?; let sandbox = client .start_sandbox(StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) @@ -3483,6 +3487,7 @@ pub async fn service_expose( let mut client = grpc_client(server, tls).await?; let response = client .expose_service(ExposeServiceRequest { + request_id: String::new(), sandbox: sandbox.to_string(), service: service.to_string(), target_port: u32::from(target_port), @@ -3609,6 +3614,7 @@ pub async fn service_delete( let mut client = grpc_client(server, tls).await?; let response = client .delete_service(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: sandbox.to_string(), service: service.to_string(), @@ -5882,6 +5888,7 @@ pub async fn sandbox_draft_approve( let response = client .approve_draft_chunk(ApproveDraftChunkRequest { + request_id: String::new(), name: name.to_string(), chunk_id: chunk_id.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -5914,6 +5921,7 @@ pub async fn sandbox_draft_reject( client .reject_draft_chunk(RejectDraftChunkRequest { + request_id: String::new(), name: name.to_string(), chunk_id: chunk_id.to_string(), reason: reason.to_string(), @@ -5955,6 +5963,7 @@ pub async fn sandbox_draft_approve_all( let response = client .approve_all_draft_chunks(ApproveAllDraftChunksRequest { + request_id: String::new(), name: name.to_string(), include_security_flagged, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -5986,6 +5995,7 @@ pub async fn sandbox_draft_clear( let response = client .clear_draft_chunks(ClearDraftChunksRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index dde37190e6..0419bbd225 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -308,6 +308,7 @@ mod tests { let codec = ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], ..SandboxSpec::default() @@ -336,6 +337,7 @@ mod tests { fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() { let codec = ProtoJsonCodec::openshell().unwrap(); let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { r#type: "github".to_string(), credentials: HashMap::from([( @@ -396,6 +398,7 @@ mod tests { fn generic_sandbox_environment_remains_visible() { let codec = ProtoJsonCodec::openshell().unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), ..SandboxSpec::default() diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 0bd1ba2b47..91ea949feb 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -755,6 +755,7 @@ mod tests { fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation { let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { r#type: "github".to_string(), credentials: HashMap::from([( @@ -1009,6 +1010,7 @@ mod tests { codec: codec.clone(), }; let request = UpdateConfigRequest { + request_id: String::new(), name: "demo".to_string(), expected_resource_version: u64::MAX - 1, annotations: HashMap::from([ @@ -1049,6 +1051,7 @@ mod tests { let codec = ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { template: Some(SandboxTemplate { resources: Some( diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index b98e34cc61..473f34d625 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -356,6 +356,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), @@ -374,6 +375,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), }; @@ -388,6 +390,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), }; @@ -950,6 +953,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), @@ -969,6 +973,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; @@ -984,6 +989,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; @@ -1181,6 +1187,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { gpu: Some(proto::GpuResourceRequirements { count: None }), }); proto::CreateSandboxRequest { + request_id: String::new(), spec: Some(proto::SandboxSpec { environment, template, @@ -1212,6 +1219,7 @@ fn create_sandbox_from_template_request( policy, } = spec; proto::CreateSandboxRequest { + request_id: String::new(), spec: Some(proto::SandboxSpec { providers, command, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index d3bb7119b7..c0f6f926d5 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -4,7 +4,7 @@ //! gRPC service implementation. mod auth_rpc; -mod mutation_replay; +pub mod mutation_replay; pub mod policy; pub mod provider; mod sandbox; @@ -267,7 +267,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_create_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn begin_rootfs_tar_staging( @@ -339,35 +339,35 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_attach_sandbox_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn detach_sandbox_provider( &self, request: Request, ) -> Result, Status> { - sandbox::handle_detach_sandbox_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_delete_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_stop_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn start_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_start_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } // --- Exec --- @@ -413,7 +413,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - service::handle_expose_service(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_service( @@ -434,7 +434,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - service::handle_delete_service(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn revoke_ssh_session( @@ -450,7 +450,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_create_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_provider( @@ -485,14 +485,14 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_import_provider_profiles(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn update_provider_profiles( &self, request: Request, ) -> Result, Status> { - provider::handle_update_provider_profiles(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn lint_provider_profiles( @@ -506,7 +506,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_update_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_provider_refresh_status( @@ -520,35 +520,35 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_configure_provider_refresh(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn rotate_provider_credential( &self, request: Request, ) -> Result, Status> { - provider::handle_rotate_provider_credential(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider_refresh( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider_refresh(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider_profile( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider_profile(&self.state, request).await + mutation_replay::run(&self.state, request).await } // --- Config / Policy --- @@ -585,7 +585,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - policy::handle_update_config(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_sandbox_policy_status( @@ -652,42 +652,42 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - policy::handle_approve_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn reject_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_reject_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn approve_all_draft_chunks( &self, request: Request, ) -> Result, Status> { - policy::handle_approve_all_draft_chunks(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn edit_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_edit_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn undo_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_undo_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn clear_draft_chunks( &self, request: Request, ) -> Result, Status> { - policy::handle_clear_draft_chunks(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_draft_history( diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index 4e2cc78c7e..e5bb5a236a 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -3,13 +3,14 @@ //! Explicitly opted-in unary mutations. This is an admission fence, not a lease: //! an owner that cannot persist success leaves an unresolved claim forever. -//! Intercepted and credential-bearing RPCs must not use this adapter unchanged. +//! Each adapter explicitly approves its authorization and replay representation. #![allow(clippy::result_large_err)] use std::collections::BTreeMap; use std::sync::{Arc, LazyLock}; +use hmac::{Hmac, Mac}; use openshell_core::proto::{ AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateSandboxTemplateRequest, CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteSandboxTemplateRequest, @@ -52,16 +53,31 @@ struct Admission { // change must reject incompatible records, not admit the old key again. format_version: u32, payload_hash: String, + #[serde(default)] + protection: Option, workspace_id: Option, success: Option, completed_at_ms: Option, } +#[derive(Serialize, Deserialize, PartialEq)] +struct Protection { + key_id: String, + effective_payload_hash: String, + effective_workspace_id: Option, +} + +/// Gateway-owned, in-memory input before hydration and interceptor modification. +/// Never populated from HTTP metadata and never written to the admission store. +#[derive(Clone)] +pub struct OriginalMutation(pub(crate) Vec); + /// Deliberately no serialized requests, responses, tokens, or error messages. #[derive(Serialize, Deserialize)] pub(super) enum Success { Resource { id: String, version: u64 }, Deletion { outcome: i32 }, + Ordinary(ordinary::Outcome), } pub(super) struct Scope { @@ -73,13 +89,14 @@ pub(super) struct Scope { pub(super) trait Mutation: Message + Default + Send + Sync + 'static { type Output: Message + Default + Send + 'static; const METHOD: &'static str; + const PROTECTED: bool = false; fn request_id(&self) -> &str; async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result; async fn execute( state: &Arc, request: Request, ) -> Result, Status>; - fn capture(response: &Self::Output) -> Result; + fn capture(response: &Response) -> Result; async fn restore(store: &Store, success: Success) -> Result; } @@ -88,7 +105,20 @@ pub(super) async fn run( state: &Arc, request: Request, ) -> Result, Status> { - if request.get_ref().request_id().is_empty() { + let original = request + .extensions() + .get::() + .map(|original| M::decode(original.0.as_slice())) + .transpose() + .map_err(|_| Status::internal("decode original mutation request"))?; + let original = original.as_ref().unwrap_or_else(|| request.get_ref()); + if original.request_id() != request.get_ref().request_id() { + return Err(rpc_error::invalid_argument( + "request_id", + "interceptors must preserve the original request_id", + )); + } + if original.request_id().is_empty() { return M::execute(state, request).await; } if request.get_ref().encoded_len() > 4 * 1024 * 1024 { @@ -101,7 +131,21 @@ pub(super) async fn run( "request admission requires a user principal", )); }; - let scope = request.get_ref().authorize(state, &principal).await?; + let scope = original.authorize(state, &principal).await?; + let effective_scope = request.get_ref().authorize(state, &principal).await?; + let (payload_hash, protection) = if M::PROTECTED { + let key = fingerprint_key(state).await?; + ( + key.fingerprint(original)?, + Some(Protection { + key_id: key.id(), + effective_payload_hash: key.fingerprint(request.get_ref())?, + effective_workspace_id: effective_scope.workspace_id, + }), + ) + } else { + (fingerprint(original)?, None) + }; let (provider, issuer) = match user.identity.provider { IdentityProvider::Oidc => ( "oidc", @@ -129,7 +173,6 @@ pub(super) async fn run( scope.name, request_id ]))?; - let payload_hash = fingerprint(request.get_ref())?; let permit = EXECUTORS.clone().try_acquire_owned().map_err(|_| { Status::resource_exhausted( "mutation admission workers are busy; no work was started by this call", @@ -140,7 +183,16 @@ pub(super) async fn run( // must not drop the owner between durable admission and execution. tokio::spawn(async move { let _permit = permit; - execute_owned(&state, request, &key, &bucket, payload_hash, scope).await + execute_owned( + &state, + request, + &key, + &bucket, + payload_hash, + protection, + scope, + ) + .await }) .await .map_err(|_| uncertain())? @@ -148,15 +200,17 @@ pub(super) async fn run( async fn execute_owned( state: &Arc, - request: Request, + mut request: Request, key: &str, bucket: &str, payload_hash: String, + protection: Option, scope: Scope, ) -> Result, Status> { let mut admission = Admission { format_version: 1, payload_hash, + protection, workspace_id: scope.workspace_id, success: None, completed_at_ms: None, @@ -188,13 +242,22 @@ async fn execute_owned( Err(error) => return Err(storage_error(error)), } } + // A rotated/unavailable protection key must never turn the old ID + // into a new admission, or report a misleading payload mismatch. + if previous.protection.as_ref().map(|p| &p.key_id) + != admission.protection.as_ref().map(|p| &p.key_id) + { + return Err(replay_unavailable()); + } if previous.payload_hash != admission.payload_hash { return Err(rpc_error::failed_precondition( "REQUEST_ID_PAYLOAD_MISMATCH", "request_id was already used with a different payload", )); } - if previous.workspace_id != admission.workspace_id { + if previous.workspace_id != admission.workspace_id + || previous.protection != admission.protection + { return Err(replay_unavailable()); } let success = previous.success.ok_or_else(uncertain)?; @@ -239,10 +302,16 @@ async fn execute_owned( }; // Any error or panic from here leaves the claim unresolved. Status codes // do not establish that a handler performed no effects. - let response = M::execute(state, request).await?; - admission.success = Some(M::capture(response.get_ref())?); + let facts = ordinary::Facts::default(); + request.extensions_mut().insert(facts.clone()); + let mut response = M::execute(state, request).await?; + response.extensions_mut().insert(facts); + admission.success = Some(M::capture(&response)?); admission.completed_at_ms = Some(current_time_ms()); let payload = serde_json::to_vec(&admission).map_err(|_| uncertain())?; + if payload.len() > 64 * 1024 { + return Err(uncertain()); + } state .store .put_if( @@ -320,6 +389,50 @@ fn fingerprint(request: &M) -> Result { hash_json(&value) } +struct FingerprintKey([u8; 32]); + +impl FingerprintKey { + fn id(&self) -> String { + format!("{:x}", Sha256::digest(self.0)) + } + + fn fingerprint(&self, request: &M) -> Result { + let mut mac = Hmac::::new_from_slice(&self.0) + .map_err(|_| Status::internal("initialize mutation fingerprint"))?; + mac.update(fingerprint(request)?.as_bytes()); + Ok(format!("{:x}", mac.finalize().into_bytes())) + } +} + +// New adapters can contain credentials or arbitrary sandbox environment values. +// A plain database hash would permit offline guesses. Reuse existing private +// gateway material without introducing a database-stored key or new deployment +// configuration. HA replicas must share that material; rotation fails closed. +async fn fingerprint_key(state: &ServerState) -> Result { + let path = state + .config + .gateway_jwt + .as_ref() + .map(|jwt| &jwt.signing_key_path) + .or_else(|| state.config.tls.as_ref().map(|tls| &tls.key_path)); + let unavailable = || { + rpc_error::failed_precondition( + "REQUEST_REPLAY_UNAVAILABLE", + "request_id on this method requires readable, stable gateway JWT or TLS private key material; no work was started by this call", + ) + }; + let bytes = tokio::fs::read(path.ok_or_else(unavailable)?) + .await + .map_err(|_| unavailable())?; + if bytes.is_empty() { + return Err(unavailable()); + } + let mut hash = Sha256::new(); + hash.update(b"openshell/mutation-fingerprint/v1\0"); + hash.update(bytes); + Ok(FingerprintKey(hash.finalize().into())) +} + // Sort every map explicitly; do not depend on serde_json's preserve_order feature. fn hash_json(value: &serde_json::Value) -> Result { struct Canonical<'a>(&'a serde_json::Value); @@ -355,7 +468,7 @@ fn uncertain() -> Status { fn replay_unavailable() -> Status { rpc_error::failed_precondition( "REQUEST_REPLAY_UNAVAILABLE", - "the original workspace or result no longer exists at its recorded version; this request was not executed again", + "the original scope, protected payload, or result is no longer replayable; this request was not executed again", ) } @@ -478,8 +591,8 @@ macro_rules! resource_mutation { ) -> Result, Status> { $handler(state, request).await } - fn capture(response: &Self::Output) -> Result { - resource_success(response.$field.as_ref()) + fn capture(response: &Response) -> Result { + resource_success(response.get_ref().$field.as_ref()) } async fn restore(store: &Store, success: Success) -> Result { Ok($resp { @@ -512,9 +625,9 @@ macro_rules! deletion_mutation { ) -> Result, Status> { $handler(state, request).await } - fn capture(response: &Self::Output) -> Result { + fn capture(response: &Response) -> Result { Ok(Success::Deletion { - outcome: response.outcome, + outcome: response.get_ref().outcome, }) } async fn restore(_store: &Store, success: Success) -> Result { @@ -587,3 +700,5 @@ deletion_mutation!( #[cfg(test)] mod tests; + +pub(super) mod ordinary; diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs new file mode 100644 index 0000000000..6013288b98 --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -0,0 +1,908 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Explicit replay recipes. Never serialize a whole public request or response: +//! sandbox specs, provider credentials and refresh material can contain secrets. + +#[cfg(test)] +pub(super) mod tests; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use openshell_core::proto::{ + ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, + ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, + ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, + ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, + DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, + EditDraftChunkResponse, ExposeServiceRequest, ImportProviderProfilesRequest, + ImportProviderProfilesResponse, Provider, ProviderProfile, ProviderProfileDiagnostic, + ProviderResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, + RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, + ServiceEndpointResponse, StartSandboxRequest, StopSandboxRequest, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, + UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, + WorkspaceSelector, +}; +use openshell_core::{GetResourceVersion, ObjectId}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use tonic::{Request, Response, Status}; + +use super::{ + Mutation, Scope, Success, global_scope, named_scope, replay_unavailable, restore_resource, + storage_error, uncertain, +}; +use crate::ServerState; +use crate::auth::principal::Principal; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, authorize_workspace, authorize_workspace_selector, +}; +use crate::grpc::{policy, provider, sandbox, service}; +use crate::persistence::{ObjectType, SetResourceVersion, Store}; +use crate::storage_proto::{ + StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState, + StoredProviderProfile, +}; + +#[derive(Clone, Serialize, Deserialize)] +pub(in crate::grpc) struct Reference { + id: String, + version: u64, +} + +impl Reference { + fn new(resource: &(impl ObjectId + GetResourceVersion)) -> Self { + Self { + id: resource.object_id().into(), + version: resource.get_resource_version(), + } + } + + async fn restore< + T: Message + Default + ObjectType + SetResourceVersion + GetResourceVersion, + >( + &self, + store: &Store, + ) -> Result { + restore_resource( + store, + Success::Resource { + id: self.id.clone(), + version: self.version, + }, + ) + .await + } +} + +/// Facts come from the handler's actual resource/write, never a later lookup by +/// mutable name. The collector only exists in the owner task and is not metadata. +#[derive(Clone, Default)] +pub struct Facts(Arc>); + +#[derive(Clone, Default)] +struct WriteFacts { + references: Vec, + refresh: Option, + global: bool, +} + +impl Facts { + pub(crate) fn global(&self) -> Result<(), Status> { + self.0.lock().map_err(|_| uncertain())?.global = true; + Ok(()) + } + pub(crate) fn from_request(request: &Request) -> Self { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() + } + + pub(crate) fn resource( + &self, + resource: &(impl ObjectId + GetResourceVersion), + ) -> Result<(), Status> { + self.0 + .lock() + .map_err(|_| uncertain())? + .references + .push(Reference::new(resource)); + Ok(()) + } + + pub(crate) fn refresh( + &self, + state: &StoredProviderCredentialRefreshState, + ) -> Result<(), Status> { + self.0.lock().map_err(|_| uncertain())?.refresh = Some(Refresh { + id: state.object_id().into(), + provider_id: state.provider_id.clone(), + epoch: crate::provider_refresh::effective_authorization_epoch(state)?.into(), + }); + Ok(()) + } +} + +fn facts(response: &Response) -> Result { + Ok(response + .extensions() + .get::() + .ok_or_else(uncertain)? + .0 + .lock() + .map_err(|_| uncertain())? + .clone()) +} + +fn parent(response: &Response) -> Result { + let facts = facts(response)?; + if facts.references.len() != 1 { + return Err(uncertain()); + } + Ok(facts.references[0].id.clone()) +} + +#[derive(Serialize, Deserialize)] +pub(in crate::grpc) enum Outcome { + Sandbox { + id: String, + changed: bool, + }, + SandboxDeletion { + id: String, + outcome: i32, + }, + Provider(Reference), + Service { + reference: Reference, + sandbox_id: String, + url: String, + }, + Profiles { + references: Vec, + diagnostics: Vec, + changed: bool, + }, + Config { + sandbox_id: Option, + version: u32, + policy_hash: String, + settings_revision: u64, + deleted: bool, + annotations: HashMap, + }, + Policy { + sandbox_id: String, + version: u32, + hash: String, + approved: u32, + skipped: u32, + cleared: u32, + }, + Refresh(Refresh), +} + +/// Public profile declarations and diagnostics have a nonsecret contract. These +/// fields may echo declaration text; they are not arbitrary sanitized payloads. +#[derive(Serialize, Deserialize)] +pub(in crate::grpc) struct Diagnostic { + source: String, + profile_id: String, + field: String, + message: String, + severity: String, +} + +impl From<&ProviderProfileDiagnostic> for Diagnostic { + fn from(value: &ProviderProfileDiagnostic) -> Self { + Self { + source: value.source.clone(), + profile_id: value.profile_id.clone(), + field: value.field.clone(), + message: value.message.clone(), + severity: value.severity.clone(), + } + } +} +impl From for ProviderProfileDiagnostic { + fn from(value: Diagnostic) -> Self { + Self { + source: value.source, + profile_id: value.profile_id, + field: value.field, + message: value.message, + severity: value.severity, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub(in crate::grpc) struct Refresh { + id: String, + provider_id: String, + epoch: String, +} + +fn outcome(success: Success) -> Result { + match success { + Success::Ordinary(value) => Ok(value), + _ => Err(replay_unavailable()), + } +} + +async fn live( + store: &Store, + id: &str, +) -> Result { + store + .get_message(id) + .await + .map_err(storage_error)? + .ok_or_else(replay_unavailable) +} + +async fn selected_scope( + state: &ServerState, + principal: &Principal, + selector: Option<&WorkspaceSelector>, + role: MinWorkspaceRole, +) -> Result { + let authz = + authorize_workspace_selector(&state.store, &state.admin_role, principal, selector, role) + .await?; + named_scope(state, &authz.workspace).await +} + +async fn profile_scope( + state: &ServerState, + principal: &Principal, + workspace: &str, +) -> Result { + if workspace.is_empty() { + return global_scope(state, principal); + } + let authz = authorize_workspace( + &state.store, + &state.admin_role, + principal, + workspace, + MinWorkspaceRole::Admin, + ) + .await?; + named_scope(state, &authz.workspace).await +} + +macro_rules! mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $auth:expr, $capture:expr, $restore:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + const PROTECTED: bool = true; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Response) -> Result { + ($capture)(response).map(Success::Ordinary) + } + async fn restore(store: &Store, success: Success) -> Result { + ($restore)(store, outcome(success)?).await + } + } + }; +} + +macro_rules! scoped_mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr) => { + mutation!( + $req, + $resp, + $method, + $handler, + async |req: &$req, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::$role, + ) + .await + }, + $capture, + $restore + ); + }; +} + +fn sandbox_receipt(sandbox: Option<&Sandbox>, changed: bool) -> Result { + Ok(Outcome::Sandbox { + id: sandbox.ok_or_else(uncertain)?.object_id().into(), + changed, + }) +} + +macro_rules! sandbox_mutation { + ($req:ty, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + SandboxResponse, + $method, + $handler, + User, + |response: &Response| sandbox_receipt( + response.get_ref().sandbox.as_ref(), + false + ), + async |store: &Store, outcome: Outcome| { + let Outcome::Sandbox { id, .. } = outcome else { + return Err(replay_unavailable()); + }; + Ok(SandboxResponse { + sandbox: Some(live(store, &id).await?), + }) + } + ); + }; +} +sandbox_mutation!( + CreateSandboxRequest, + "CreateSandbox", + sandbox::handle_create_sandbox +); +sandbox_mutation!( + StartSandboxRequest, + "StartSandbox", + sandbox::handle_start_sandbox +); +sandbox_mutation!( + StopSandboxRequest, + "StopSandbox", + sandbox::handle_stop_sandbox +); + +macro_rules! attachment_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path, $field:ident) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + User, + |response: &Response<$resp>| sandbox_receipt( + response.get_ref().sandbox.as_ref(), + response.get_ref().$field + ), + async |store: &Store, outcome: Outcome| { + let Outcome::Sandbox { id, changed } = outcome else { + return Err(replay_unavailable()); + }; + Ok($resp { + sandbox: Some(live(store, &id).await?), + $field: changed, + }) + } + ); + }; +} +attachment_mutation!( + AttachSandboxProviderRequest, + AttachSandboxProviderResponse, + "AttachSandboxProvider", + sandbox::handle_attach_sandbox_provider, + attached +); +attachment_mutation!( + DetachSandboxProviderRequest, + DetachSandboxProviderResponse, + "DetachSandboxProvider", + sandbox::handle_detach_sandbox_provider, + detached +); + +scoped_mutation!( + DeleteSandboxRequest, + DeleteSandboxResponse, + "DeleteSandbox", + sandbox::handle_delete_sandbox, + User, + |response: &Response| Ok(Outcome::SandboxDeletion { + id: response.get_ref().sandbox_id.clone(), + outcome: response.get_ref().outcome + }), + async |_store: &Store, value: Outcome| { + let Outcome::SandboxDeletion { id, outcome } = value else { + return Err(replay_unavailable()); + }; + Ok(DeleteSandboxResponse { + sandbox_id: id, + outcome, + }) + } +); + +scoped_mutation!( + ExposeServiceRequest, + ServiceEndpointResponse, + "ExposeService", + service::handle_expose_service, + User, + |response: &Response| { + let value = response.get_ref(); + let endpoint = value.endpoint.as_ref().ok_or_else(uncertain)?; + Ok(Outcome::Service { + reference: Reference::new(endpoint), + sandbox_id: endpoint.sandbox_id.clone(), + url: value.url.clone(), + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Service { + reference, + sandbox_id, + url, + } = outcome + else { + return Err(replay_unavailable()); + }; + let _: Sandbox = live(store, &sandbox_id).await?; + Ok(ServiceEndpointResponse { + endpoint: Some(reference.restore(store).await?), + url, + }) + } +); + +macro_rules! provider_mutation { + ($req:ty, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + ProviderResponse, + $method, + $handler, + Admin, + |response: &Response| Ok(Outcome::Provider(Reference::new( + response.get_ref().provider.as_ref().ok_or_else(uncertain)? + ))), + async |store: &Store, outcome: Outcome| { + let Outcome::Provider(reference) = outcome else { + return Err(replay_unavailable()); + }; + Ok(ProviderResponse { + provider: Some(provider::redact_provider_credentials( + reference.restore(store).await?, + )), + }) + } + ); + }; +} +provider_mutation!( + CreateProviderRequest, + "CreateProvider", + provider::handle_create_provider +); +provider_mutation!( + UpdateProviderRequest, + "UpdateProvider", + provider::handle_update_provider +); + +// Terminal receipts deliberately do not resolve a deleted parent or replacement. +macro_rules! ordinary_deletion { + ($req:ty, $resp:ident, $method:literal, $handler:path, $auth:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + const PROTECTED: bool = true; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Response) -> Result { + Ok(Success::Deletion { + outcome: response.get_ref().outcome, + }) + } + async fn restore(_store: &Store, success: Success) -> Result { + let Success::Deletion { outcome } = success else { + return Err(replay_unavailable()); + }; + Ok($resp { outcome }) + } + } + }; +} +ordinary_deletion!( + DeleteServiceRequest, + DeleteServiceResponse, + "DeleteService", + service::handle_delete_service, + async |req: &DeleteServiceRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderRequest, + DeleteProviderResponse, + "DeleteProvider", + provider::handle_delete_provider, + async |req: &DeleteProviderRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, + "DeleteProviderRefresh", + provider::handle_delete_provider_refresh, + async |req: &DeleteProviderRefreshRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderProfileRequest, + DeleteProviderProfileResponse, + "DeleteProviderProfile", + provider::handle_delete_provider_profile, + async |req: &DeleteProviderProfileRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + } +); + +async fn restore_profiles( + store: &Store, + references: Vec, +) -> Result, Status> { + let mut profiles = Vec::with_capacity(references.len()); + for reference in references { + let stored: StoredProviderProfile = reference.restore(store).await?; + profiles.push(crate::provider_profile_sources::profile_response_payload( + stored.profile.ok_or_else(replay_unavailable)?, + reference.version, + )); + } + Ok(profiles) +} + +mutation!( + ImportProviderProfilesRequest, + ImportProviderProfilesResponse, + "ImportProviderProfiles", + provider::handle_import_provider_profiles, + async |req: &ImportProviderProfilesRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + }, + |response: &Response| { + let value = response.get_ref(); + let references = facts(response)?.references; + if references.len() != value.profiles.len() { + return Err(uncertain()); + } + Ok(Outcome::Profiles { + references, + diagnostics: value.diagnostics.iter().map(Diagnostic::from).collect(), + changed: value.imported, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Profiles { + references, + diagnostics, + changed, + } = outcome + else { + return Err(replay_unavailable()); + }; + Ok(ImportProviderProfilesResponse { + profiles: restore_profiles(store, references).await?, + diagnostics: diagnostics.into_iter().map(Into::into).collect(), + imported: changed, + }) + } +); + +mutation!( + UpdateProviderProfilesRequest, + UpdateProviderProfilesResponse, + "UpdateProviderProfiles", + provider::handle_update_provider_profiles, + async |req: &UpdateProviderProfilesRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + }, + |response: &Response| { + let value = response.get_ref(); + let references = facts(response)?.references; + if references.len() != usize::from(value.profile.is_some()) { + return Err(uncertain()); + } + Ok(Outcome::Profiles { + references, + diagnostics: value.diagnostics.iter().map(Diagnostic::from).collect(), + changed: value.updated, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Profiles { + references, + diagnostics, + changed, + } = outcome + else { + return Err(replay_unavailable()); + }; + let mut profiles = restore_profiles(store, references).await?; + Ok(UpdateProviderProfilesResponse { + profile: profiles.pop(), + diagnostics: diagnostics.into_iter().map(Into::into).collect(), + updated: changed, + }) + } +); + +macro_rules! refresh_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + Admin, + |response: &Response<$resp>| Ok(Outcome::Refresh( + facts(response)?.refresh.ok_or_else(uncertain)? + )), + async |store: &Store, outcome: Outcome| { + let Outcome::Refresh(reference) = outcome else { + return Err(replay_unavailable()); + }; + let state: StoredProviderCredentialRefreshState = + live(store, &reference.id).await?; + let _: Provider = live(store, &reference.provider_id).await?; + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_time.is_some()) + || state.provider_id != reference.provider_id + || crate::provider_refresh::effective_authorization_epoch(&state)? + != reference.epoch + { + return Err(replay_unavailable()); + } + Ok($resp { + status: Some(crate::provider_refresh::refresh_status_from_state(&state)), + }) + } + ); + }; +} +refresh_mutation!( + ConfigureProviderRefreshRequest, + ConfigureProviderRefreshResponse, + "ConfigureProviderRefresh", + provider::handle_configure_provider_refresh +); +refresh_mutation!( + RotateProviderCredentialRequest, + RotateProviderCredentialResponse, + "RotateProviderCredential", + provider::handle_rotate_provider_credential +); + +mutation!( + UpdateConfigRequest, + UpdateConfigResponse, + "UpdateConfig", + policy::handle_update_config, + async |req: &UpdateConfigRequest, state: &ServerState, principal: &Principal| { + if req.global { + if req.workspace_scope.is_some() { + return Err(Status::invalid_argument( + "workspace_scope must be omitted when global is true", + )); + } + global_scope(state, principal) + } else { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } + }, + |response: &Response| { + let value = response.get_ref(); + let facts = facts(response)?; + let references = facts.references; + if references.len() != usize::from(!facts.global) { + return Err(uncertain()); + } + Ok(Outcome::Config { + sandbox_id: references.first().map(|r| r.id.clone()), + version: value.version, + policy_hash: value.policy_hash.clone(), + settings_revision: value.settings_revision, + deleted: value.deleted, + annotations: value.annotations.clone(), + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Config { + sandbox_id, + version, + policy_hash, + settings_revision, + deleted, + annotations, + } = outcome + else { + return Err(replay_unavailable()); + }; + if let Some(id) = sandbox_id { + let _: Sandbox = live(store, &id).await?; + } + Ok(UpdateConfigResponse { + version, + policy_hash, + settings_revision, + deleted, + annotations, + }) + } +); + +macro_rules! policy_mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $values:expr, $restore:expr) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + Admin, + |response: &Response<$resp>| { + let (version, hash, approved, skipped, cleared) = ($values)(response.get_ref()); + Ok(Outcome::Policy { + sandbox_id: parent(response)?, + version, + hash, + approved, + skipped, + cleared, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Policy { + sandbox_id, + version, + hash, + approved, + skipped, + cleared, + } = outcome + else { + return Err(replay_unavailable()); + }; + let _: Sandbox = live(store, &sandbox_id).await?; + Ok(($restore)(version, hash, approved, skipped, cleared)) + } + ); + }; +} +policy_mutation!( + ApproveDraftChunkRequest, + ApproveDraftChunkResponse, + "ApproveDraftChunk", + policy::handle_approve_draft_chunk, + |v: &ApproveDraftChunkResponse| (v.policy_version, v.policy_hash.clone(), 0, 0, 0), + |version, hash, _, _, _| ApproveDraftChunkResponse { + policy_version: version, + policy_hash: hash + } +); +policy_mutation!( + UndoDraftChunkRequest, + UndoDraftChunkResponse, + "UndoDraftChunk", + policy::handle_undo_draft_chunk, + |v: &UndoDraftChunkResponse| (v.policy_version, v.policy_hash.clone(), 0, 0, 0), + |version, hash, _, _, _| UndoDraftChunkResponse { + policy_version: version, + policy_hash: hash + } +); +policy_mutation!( + ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, + "ApproveAllDraftChunks", + policy::handle_approve_all_draft_chunks, + |v: &ApproveAllDraftChunksResponse| ( + v.policy_version, + v.policy_hash.clone(), + v.chunks_approved, + v.chunks_skipped, + 0 + ), + |version, hash, approved, skipped, _| ApproveAllDraftChunksResponse { + policy_version: version, + policy_hash: hash, + chunks_approved: approved, + chunks_skipped: skipped + } +); +policy_mutation!( + ClearDraftChunksRequest, + ClearDraftChunksResponse, + "ClearDraftChunks", + policy::handle_clear_draft_chunks, + |v: &ClearDraftChunksResponse| (0, String::new(), 0, 0, v.chunks_cleared), + |_, _, _, _, cleared| ClearDraftChunksResponse { + chunks_cleared: cleared + } +); +policy_mutation!( + RejectDraftChunkRequest, + RejectDraftChunkResponse, + "RejectDraftChunk", + policy::handle_reject_draft_chunk, + |_: &RejectDraftChunkResponse| (0, String::new(), 0, 0, 0), + |_, _, _, _, _| RejectDraftChunkResponse {} +); +policy_mutation!( + EditDraftChunkRequest, + EditDraftChunkResponse, + "EditDraftChunk", + policy::handle_edit_draft_chunk, + |_: &EditDraftChunkResponse| (0, String::new(), 0, 0, 0), + |_, _, _, _, _| EditDraftChunkResponse {} +); diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs new file mode 100644 index 0000000000..653b0de47b --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -0,0 +1,954 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::grpc::mutation_replay::tests::reason; +use crate::grpc::mutation_replay::{Admission, OBJECT_TYPE, OriginalMutation, fingerprint, run}; +use crate::grpc::test_support::{authed_request, test_server_state}; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + DeletionOutcome, DraftChunkApproval, GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, + NetworkPolicyRule, PolicyChunk, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, + ProviderCredentialRefreshStrategy, ProviderProfileCategory, ProviderProfileCredential, + ProviderProfileImportItem, SandboxPhase, SandboxPolicy, SandboxSpec, ServiceEndpoint, + SettingValue, SubmitPolicyAnalysisRequest, WorkspaceMember, WorkspaceRole, setting_value, + workspace_selector, +}; +use tonic::Code; + +async fn protected_state() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let mut state = test_server_state().await; + configure_key(&mut state, &directory); + (directory, state) +} + +fn configure_key(state: &mut Arc, directory: &tempfile::TempDir) { + let key = directory.path().join("private-key"); + std::fs::write(&key, b"test-only stable private fingerprint material").unwrap(); + Arc::get_mut(state).unwrap().config.gateway_jwt = + Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); +} + +pub(in crate::grpc::mutation_replay) async fn exercise_protected_backend(url: &str) { + use crate::grpc::mutation_replay::tests::state_for; + let directory = tempfile::tempdir().unwrap(); + let mut first = state_for(Store::connect(url).await.unwrap()).await; + let mut second = state_for(Store::connect(url).await.unwrap()).await; + configure_key(&mut first, &directory); + configure_key(&mut second, &directory); + let req = DeleteSandboxRequest { + name: "keyed-restart".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + let mut tasks = Vec::new(); + for index in 0..16 { + let state = if index % 2 == 0 { + first.clone() + } else { + second.clone() + }; + let req = req.clone(); + tasks.push(tokio::spawn(async move { + run(&state, authed_request(req)).await + })); + } + for task in tasks { + if let Err(status) = task.await.unwrap() { + assert_eq!(reason(&status), "REQUEST_OUTCOME_UNCERTAIN"); + } + } + drop(first); + drop(second); + let mut restarted = state_for(Store::connect(url).await.unwrap()).await; + configure_key(&mut restarted, &directory); + let replacement = Sandbox { + metadata: Some(meta(&req.name)), + ..Default::default() + }; + restarted.store.put_message(&replacement).await.unwrap(); + assert_eq!( + replay(&restarted, req).await.outcome, + i32::from(DeletionOutcome::AlreadyAbsent) + ); + assert!( + restarted + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn ordinary_replay_reauthorizes_workspace_role() { + let (_directory, mut state) = protected_state().await; + Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".into(); + let req = DeleteProviderRequest { + name: "missing".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + run(&state, authed_request(req.clone())).await.unwrap(); + let member = WorkspaceMember { + metadata: Some(meta("dev-user")), + principal_subject: "dev-user".into(), + role: WorkspaceRole::User.into(), + }; + state.store.put_message(&member).await.unwrap(); + let mut request = authed_request(req); + let principal = request.extensions_mut().get_mut::().unwrap(); + if let Principal::User(user) = principal { + user.identity.roles.clear(); + } + let principal = principal.clone(); + assert!( + CreateSandboxRequest { + workspace_scope: Some(scope()), + ..Default::default() + } + .authorize(&state, &principal) + .await + .is_ok() + ); + assert_eq!( + run(&state, request).await.unwrap_err().code(), + Code::PermissionDenied + ); + for global in [false, true] { + let req = UpdateConfigRequest { + global, + workspace_scope: if global { None } else { Some(scope()) }, + ..Default::default() + }; + assert!(req.authorize(&state, &principal).await.is_err()); + } + assert!( + ImportProviderProfilesRequest::default() + .authorize(&state, &principal) + .await + .is_err() + ); +} + +#[tokio::test] +async fn oversized_public_diagnostics_leave_a_bounded_unresolved_claim() { + let (_directory, state) = protected_state().await; + let request = ImportProviderProfilesRequest { + request_id: id(), + profiles: (0..80) + .map(|_| ProviderProfileImportItem { + profile: None, + source: "public-source".repeat(100), + }) + .collect(), + ..Default::default() + }; + for _ in 0..2 { + assert_eq!( + reason( + &run(&state, authed_request(request.clone())) + .await + .unwrap_err() + ), + "REQUEST_OUTCOME_UNCERTAIN" + ); + } + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].payload.len() < 1024); + assert!( + serde_json::from_slice::(&rows[0].payload) + .unwrap() + .success + .is_none() + ); +} + +fn scope() -> WorkspaceSelector { + workspace_selector("default") +} +fn id() -> String { + uuid::Uuid::new_v4().to_string() +} +fn meta(name: &str) -> ObjectMeta { + ObjectMeta { + id: id(), + name: name.into(), + workspace: "default".into(), + ..Default::default() + } +} + +fn create_sandbox(name: &str) -> CreateSandboxRequest { + CreateSandboxRequest { + name: name.into(), + request_id: id(), + workspace_scope: Some(scope()), + spec: Some(SandboxSpec { + environment: HashMap::from([("PRIVATE_VALUE".into(), "low-entropy-secret".into())]), + ..Default::default() + }), + ..Default::default() + } +} + +async fn replay(state: &Arc, req: M) -> M::Output { + let response = run(state, authed_request(req)).await.unwrap(); + assert_eq!( + response.metadata().get("openshell-replayed").unwrap(), + "true" + ); + response.into_inner() +} + +#[tokio::test] +async fn sandbox_replay_survives_status_churn_but_never_selects_replacement() { + let (_directory, state) = protected_state().await; + let req = create_sandbox("original"); + let created = run(&state, authed_request(req.clone())) + .await + .unwrap() + .into_inner() + .sandbox + .unwrap(); + state + .store + .update_message_cas::( + created.object_id(), + created.get_resource_version(), + |sandbox| { + sandbox.status.as_mut().unwrap().phase = SandboxPhase::Ready.into(); + }, + ) + .await + .unwrap(); + let returned = replay(&state, req.clone()).await.sandbox.unwrap(); + assert_eq!(returned.object_id(), created.object_id()); + assert!(returned.get_resource_version() > created.get_resource_version()); + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + let receipt = String::from_utf8(rows[0].payload.clone()).unwrap(); + assert!(!receipt.contains("low-entropy-secret")); + assert!(!receipt.contains("PRIVATE_VALUE")); + assert!(!receipt.contains(&fingerprint(&req).unwrap())); + state + .store + .delete(Sandbox::object_type(), created.object_id()) + .await + .unwrap(); + let replacement = Sandbox { + metadata: Some(meta("original")), + ..Default::default() + }; + state.store.put_message(&replacement).await.unwrap(); + assert_eq!( + reason(&run(&state, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert!( + state + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn keyed_fingerprints_fail_closed_on_missing_or_rotated_keys() { + let missing = test_server_state().await; + let req = DeleteSandboxRequest { + name: "missing".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + assert_eq!( + reason( + &run(&missing, authed_request(req.clone())) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert!( + missing + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .is_empty() + ); + let (directory, state) = protected_state().await; + run(&state, authed_request(req.clone())).await.unwrap(); + std::fs::write( + directory.path().join("private-key"), + b"rotated-private-material", + ) + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert_eq!( + state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn original_payload_is_identity_and_current_transformation_is_a_replay_guard() { + let (_directory, state) = protected_state().await; + let original = DeleteSandboxRequest { + name: "original".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + let mut effective = original.clone(); + effective.name = "transformed".into(); + let intercepted = |original: &DeleteSandboxRequest, effective: DeleteSandboxRequest| { + let mut request = authed_request(effective); + request + .extensions_mut() + .insert(OriginalMutation(original.encode_to_vec())); + request + }; + run(&state, intercepted(&original, effective.clone())) + .await + .unwrap(); + assert!( + run(&state, intercepted(&original, effective.clone())) + .await + .unwrap() + .metadata() + .contains_key("openshell-replayed") + ); + let mut changed = original.clone(); + changed.name = "different-original".into(); + assert_eq!( + reason( + &run(&state, intercepted(&changed, effective.clone())) + .await + .unwrap_err() + ), + "REQUEST_ID_PAYLOAD_MISMATCH" + ); + effective.name = "changed-transformation".into(); + assert_eq!( + reason( + &run(&state, intercepted(&original, effective)) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn interceptors_cannot_enable_disable_or_replace_request_ids() { + let state = test_server_state().await; + for (original_id, effective_id) in [(String::new(), id()), (id(), String::new()), (id(), id())] + { + let original = CreateSandboxRequest { + request_id: original_id, + ..Default::default() + }; + let mut req = authed_request(CreateSandboxRequest { + request_id: effective_id, + ..Default::default() + }); + req.extensions_mut() + .insert(OriginalMutation(original.encode_to_vec())); + assert_eq!( + run(&state, req).await.unwrap_err().code(), + Code::InvalidArgument + ); + } + assert!( + state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn service_deletion_replays_without_parent_and_does_not_delete_replacement() { + let (_directory, state) = protected_state().await; + let sandbox = Sandbox { + metadata: Some(meta("service-parent")), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + let expose = ExposeServiceRequest { + sandbox: "service-parent".into(), + service: "web".into(), + target_port: 8080, + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let endpoint = run(&state, authed_request(expose.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, expose.clone()).await, endpoint); + let delete = DeleteServiceRequest { + sandbox: "service-parent".into(), + service: "web".into(), + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let deleted = run(&state, authed_request(delete.clone())) + .await + .unwrap() + .into_inner(); + let mut fresh = expose; + fresh.request_id = id(); + let replacement = run(&state, authed_request(fresh)) + .await + .unwrap() + .into_inner() + .endpoint + .unwrap(); + state + .store + .delete(Sandbox::object_type(), sandbox.object_id()) + .await + .unwrap(); + assert_eq!(replay(&state, delete).await, deleted); + assert!( + state + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn provider_replay_is_redacted_and_update_does_not_recheck_stale_version() { + let (_directory, state) = protected_state().await; + let create = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(meta("replay-provider")), + r#type: "openai".into(), + credentials: HashMap::from([("OPENAI_API_KEY".into(), "provider-secret".into())]), + ..Default::default() + }), + workspace_scope: Some(scope()), + request_id: id(), + }; + let first = run(&state, authed_request(create.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, create).await, first); + let mut provider = first.provider.unwrap(); + provider.credentials = HashMap::from([("OPENAI_API_KEY".into(), "updated-secret".into())]); + let update = UpdateProviderRequest { + provider: Some(provider), + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let updated = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, update).await, updated); + let provider = updated.provider.unwrap(); + assert!(provider.credential_handles.is_empty()); + assert!( + provider + .credentials + .values() + .all(|value| value == "REDACTED") + ); + for row in state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + { + let receipt = String::from_utf8(row.payload).unwrap(); + assert!(!receipt.contains("provider-secret")); + assert!(!receipt.contains("updated-secret")); + } +} + +#[tokio::test] +async fn profile_receipts_use_private_identity_and_preserve_diagnostics() { + let (_directory, state) = protected_state().await; + let profile = ProviderProfile { + id: "replay-profile".into(), + display_name: "Replay".into(), + category: ProviderProfileCategory::Other.into(), + ..Default::default() + }; + let create = ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "test.yaml".into(), + }], + workspace: "default".into(), + request_id: id(), + }; + let first = run(&state, authed_request(create.clone())) + .await + .unwrap() + .into_inner(); + assert!(first.imported, "{:?}", first.diagnostics); + assert_eq!(replay(&state, create.clone()).await, first); + let mut profile = first.profiles[0].clone(); + profile.display_name = "Updated".into(); + let update = UpdateProviderProfilesRequest { + id: profile.id.clone(), + profile: Some(ProviderProfileImportItem { + profile: Some(profile), + source: "update.yaml".into(), + }), + workspace: "default".into(), + request_id: id(), + ..Default::default() + }; + let updated = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert!(updated.updated); + assert_eq!(replay(&state, update).await, updated); + assert_eq!( + reason(&run(&state, authed_request(create)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + let invalid = ImportProviderProfilesRequest { + request_id: id(), + ..Default::default() + }; + let diagnostics = run(&state, authed_request(invalid.clone())) + .await + .unwrap() + .into_inner(); + assert!(!diagnostics.imported); + assert!(!diagnostics.diagnostics.is_empty()); + assert_eq!(replay(&state, invalid).await, diagnostics); +} + +#[tokio::test] +async fn config_and_clear_receipts_preserve_revision_and_parent_lifetime() { + let (_directory, state) = protected_state().await; + let sandbox = Sandbox { + metadata: Some(meta("policy-parent")), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + for global in [false, true] { + let update = UpdateConfigRequest { + name: if global { + String::new() + } else { + "policy-parent".into() + }, + global, + setting_key: "ocsf_json_enabled".into(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::BoolValue(true)), + }), + workspace_scope: if global { None } else { Some(scope()) }, + request_id: id(), + ..Default::default() + }; + let first = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, update).await, first); + } + let clear = ClearDraftChunksRequest { + name: "policy-parent".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let first = run(&state, authed_request(clear.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, clear.clone()).await, first); + state + .store + .delete(Sandbox::object_type(), sandbox.object_id()) + .await + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(clear)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn refresh_receipts_never_replay_a_new_grant_epoch() { + let state = test_server_state().await; + let provider = Provider { + metadata: Some(meta("refresh-parent")), + ..Default::default() + }; + state.store.put_message(&provider).await.unwrap(); + let mut refresh = StoredProviderCredentialRefreshState { + metadata: Some(meta("refresh-record")), + provider_id: provider.object_id().into(), + authorization_epoch: id(), + ..Default::default() + }; + state.store.put_message(&refresh).await.unwrap(); + let receipt = || { + Success::Ordinary(Outcome::Refresh(Refresh { + id: refresh.object_id().into(), + provider_id: provider.object_id().into(), + epoch: refresh.authorization_epoch.clone(), + })) + }; + ConfigureProviderRefreshRequest::restore(&state.store, receipt()) + .await + .unwrap(); + let original = receipt(); + refresh.authorization_epoch = id(); + state.store.put_message(&refresh).await.unwrap(); + assert_eq!( + reason( + &ConfigureProviderRefreshRequest::restore(&state.store, original) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn refresh_receipts_reject_epoch_deletion_timestamps() { + let state = test_server_state().await; + let provider = Provider { + metadata: Some(meta("deleted-refresh-parent")), + ..Default::default() + }; + state.store.put_message(&provider).await.unwrap(); + let refresh = StoredProviderCredentialRefreshState { + metadata: Some(ObjectMeta { + deletion_time: Some(prost_types::Timestamp::default()), + ..meta("deleted-refresh-record") + }), + provider_id: provider.object_id().into(), + authorization_epoch: id(), + ..Default::default() + }; + state.store.put_message(&refresh).await.unwrap(); + let receipt = || { + Success::Ordinary(Outcome::Refresh(Refresh { + id: refresh.object_id().into(), + provider_id: provider.object_id().into(), + epoch: refresh.authorization_epoch.clone(), + })) + }; + for error in [ + ConfigureProviderRefreshRequest::restore(&state.store, receipt()) + .await + .unwrap_err(), + RotateProviderCredentialRequest::restore(&state.store, receipt()) + .await + .unwrap_err(), + ] { + assert_eq!(reason(&error), "REQUEST_REPLAY_UNAVAILABLE"); + } +} + +#[tokio::test] +async fn configure_and_rotate_capture_actual_grant_without_repeating_token_exchange() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + let (_directory, state) = protected_state().await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "minted-secret", "token_type": "Bearer", "expires_in": 3600, + }))) + .expect(1) + .mount(&server) + .await; + let strategy = ProviderCredentialRefreshStrategy::Oauth2ClientCredentials; + let profile = ProviderProfile { + id: "refresh-profile".into(), + display_name: "Refresh".into(), + category: ProviderProfileCategory::Other.into(), + credentials: vec![ProviderProfileCredential { + name: "access_token".into(), + env_vars: vec!["ACCESS_TOKEN".into()], + auth_style: "bearer".into(), + header_name: "Authorization".into(), + refresh: Some(ProviderCredentialRefresh { + strategy: strategy.into(), + token_url: server.uri(), + material: vec![ + ProviderCredentialRefreshMaterial { + name: "client_id".into(), + required: true, + ..Default::default() + }, + ProviderCredentialRefreshMaterial { + name: "client_secret".into(), + secret: true, + required: true, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + let imported = run( + &state, + authed_request(ImportProviderProfilesRequest { + workspace: "default".into(), + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "refresh.yaml".into(), + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "{:?}", imported.diagnostics); + run( + &state, + authed_request(CreateProviderRequest { + workspace_scope: Some(scope()), + provider: Some(Provider { + metadata: Some(meta("refresh-provider")), + r#type: "refresh-profile".into(), + profile_workspace: "default".into(), + credentials: HashMap::from([("ACCESS_TOKEN".into(), "initial-secret".into())]), + ..Default::default() + }), + ..Default::default() + }), + ) + .await + .unwrap(); + let configure = ConfigureProviderRefreshRequest { + provider: "refresh-provider".into(), + credential_key: "ACCESS_TOKEN".into(), + strategy: strategy.into(), + workspace_scope: Some(scope()), + request_id: id(), + material: HashMap::from([ + ("client_id".into(), "client".into()), + ("client_secret".into(), "configured-secret".into()), + ]), + ..Default::default() + }; + let configured = run(&state, authed_request(configure.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, configure.clone()).await, configured); + let rotate = RotateProviderCredentialRequest { + provider: "refresh-provider".into(), + credential_key: "ACCESS_TOKEN".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let rotated = run(&state, authed_request(rotate.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, rotate).await, rotated); + assert_eq!(replay(&state, configure).await.status, rotated.status); + for row in state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + { + let receipt = String::from_utf8(row.payload).unwrap(); + for secret in ["initial-secret", "configured-secret", "minted-secret"] { + assert!(!receipt.contains(secret)); + } + } + server.verify().await; +} + +#[tokio::test] +async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { + let (_directory, state) = protected_state().await; + let name = "draft-parent"; + let sandbox = Sandbox { + metadata: Some(meta(name)), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy::default()), + ..Default::default() + }), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + let rule = |name: &str| NetworkPolicyRule { + name: name.into(), + endpoints: vec![NetworkEndpoint { + host: format!("{name}.example.com"), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + }], + }; + let submitted = policy::handle_submit_policy_analysis( + &state, + authed_request(SubmitPolicyAnalysisRequest { + name: name.into(), + analysis_mode: "agent_authored".into(), + proposed_chunks: ["alpha", "beta", "gamma"] + .into_iter() + .map(|name| PolicyChunk { + rule_name: name.into(), + proposed_rule: Some(rule(name)), + ..Default::default() + }) + .collect(), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!( + submitted.accepted_chunk_ids.len(), + 3, + "{:?}", + submitted.rejection_reasons + ); + let edit = EditDraftChunkRequest { + name: name.into(), + chunk_id: submitted.accepted_chunk_ids[0].clone(), + proposed_rule: Some(rule("edited")), + workspace_scope: Some(scope()), + request_id: id(), + }; + run(&state, authed_request(edit.clone())).await.unwrap(); + let draft = policy::handle_get_draft_policy( + &state, + authed_request(GetDraftPolicyRequest { + name: name.into(), + workspace_scope: Some(scope()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let token = |id: &str| { + draft + .chunks + .iter() + .find(|chunk| chunk.id == id) + .unwrap() + .review_token + .clone() + }; + let approve = ApproveDraftChunkRequest { + name: name.into(), + chunk_id: edit.chunk_id.clone(), + review_token: token(&edit.chunk_id), + workspace_scope: Some(scope()), + request_id: id(), + }; + let approved = run(&state, authed_request(approve.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, approve.clone()).await, approved); + replay(&state, edit).await; + let undo = UndoDraftChunkRequest { + name: name.into(), + chunk_id: approve.chunk_id.clone(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let undone = run(&state, authed_request(undo.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, undo).await, undone); + assert_eq!(replay(&state, approve).await, approved); + let reject = RejectDraftChunkRequest { + name: name.into(), + chunk_id: submitted.accepted_chunk_ids[1].clone(), + reason: "not needed".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + run(&state, authed_request(reject.clone())).await.unwrap(); + replay(&state, reject).await; + let draft = policy::handle_get_draft_policy( + &state, + authed_request(GetDraftPolicyRequest { + name: name.into(), + workspace_scope: Some(scope()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let chunk = draft + .chunks + .iter() + .find(|chunk| chunk.id == submitted.accepted_chunk_ids[2]) + .unwrap(); + let all = ApproveAllDraftChunksRequest { + name: name.into(), + approvals: vec![DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }], + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let approved = run(&state, authed_request(all.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(approved.chunks_approved, 1); + assert_eq!(replay(&state, all).await, approved); +} diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index a75b5d7f59..e10cb700c4 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -23,7 +23,7 @@ fn create(name: &str) -> CreateWorkspaceRequest { } } -fn reason(status: &Status) -> String { +pub(super) fn reason(status: &Status) -> String { assert!(status.get_error_details().retry_info().is_none()); status .get_error_details() @@ -33,7 +33,7 @@ fn reason(status: &Status) -> String { .clone() } -async fn state_for(store: Store) -> Arc { +pub(super) async fn state_for(store: Store) -> Arc { let store = Arc::new(store); crate::ensure_default_workspace(&store).await.unwrap(); let compute = crate::compute::new_test_runtime(store.clone()).await; @@ -170,6 +170,7 @@ async fn exercise_backend(url: &str) { "REQUEST_REPLAY_UNAVAILABLE" ); exercise_expiry_and_uncertainty(&restarted).await; + ordinary::tests::exercise_protected_backend(url).await; } #[tokio::test] @@ -487,8 +488,8 @@ impl Mutation for ControlledCreate { } Ok(response) } - fn capture(response: &Self::Output) -> Result { - resource_success(response.workspace.as_ref()) + fn capture(response: &Response) -> Result { + resource_success(response.get_ref().workspace.as_ref()) } async fn restore(store: &Store, success: Success) -> Result { Ok(CreateWorkspaceResponse { @@ -543,6 +544,7 @@ async fn quota_fails_closed_but_replays_and_expired_success_cleanup_still_work() .unwrap() .remove(0); let pending = Admission { + protection: None, format_version: 1, payload_hash: "unused".into(), workspace_id: None, diff --git a/crates/openshell-server/src/grpc/mutation_tests.rs b/crates/openshell-server/src/grpc/mutation_tests.rs index e0dfcd7584..d1b135dccc 100644 --- a/crates/openshell-server/src/grpc/mutation_tests.rs +++ b/crates/openshell-server/src/grpc/mutation_tests.rs @@ -157,6 +157,7 @@ async fn allow_missing_does_not_hide_missing_parents_or_invalid_requests() { let err = provider::handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), provider: "missing-parent".into(), credential_key: "API_KEY".into(), allow_missing: true, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index d986c5cba9..4a65520827 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3393,6 +3393,7 @@ async fn handle_update_config_inner( principal: &Principal, sandbox_caller: bool, ) -> Result, Status> { + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); validate_annotations(&req.annotations, "annotations")?; let workspace = if req.global { @@ -3402,6 +3403,7 @@ async fn handle_update_config_inner( )); } require_platform_admin(&state.admin_role, principal)?; + replay_facts.global()?; String::new() } else { let min_role = if sandbox_caller { @@ -3659,6 +3661,7 @@ async fn handle_update_config_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let mut response_annotations = sandbox_metadata_annotations(&sandbox); if has_setting { @@ -5040,6 +5043,7 @@ async fn handle_approve_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5068,6 +5072,7 @@ async fn handle_approve_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5195,6 +5200,7 @@ async fn handle_reject_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5221,6 +5227,7 @@ async fn handle_reject_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5305,6 +5312,7 @@ async fn handle_approve_all_draft_chunks_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5330,6 +5338,7 @@ async fn handle_approve_all_draft_chunks_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let pending_chunks = state .store @@ -5613,6 +5622,7 @@ pub(super) async fn handle_edit_draft_chunk( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5642,6 +5652,7 @@ pub(super) async fn handle_edit_draft_chunk( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5694,6 +5705,7 @@ async fn handle_undo_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5720,6 +5732,7 @@ async fn handle_undo_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5791,6 +5804,7 @@ pub(super) async fn handle_clear_draft_chunks( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5814,6 +5828,7 @@ pub(super) async fn handle_clear_draft_chunks( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let deleted = state .store @@ -9184,6 +9199,7 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { + request_id: String::new(), global: true, setting_key: "log_level".to_string(), delete_setting: true, @@ -9278,6 +9294,7 @@ mod tests { let error = handle_update_config( &state, Request::new(UpdateConfigRequest { + request_id: String::new(), global: true, setting_key: "log_level".to_string(), delete_setting: true, @@ -11225,6 +11242,7 @@ mod tests { let error = super::super::sandbox::handle_attach_sandbox_provider( &state, authed_request(openshell_core::proto::AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "provider-ambiguity".to_string(), provider_name: "candidate-provider".to_string(), expected_resource_version: 0, @@ -11490,6 +11508,7 @@ mod tests { let response = handle_update_provider_profiles( &state, with_user(Request::new(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(updated_profile), source: "custom-policy.yaml".to_string(), @@ -12542,6 +12561,7 @@ mod tests { handle_update_provider_profiles( &state, with_user(Request::new(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(rotated_profile), source: "custom-token.yaml".to_string(), @@ -12701,6 +12721,7 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -12741,6 +12762,7 @@ mod tests { handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -12793,6 +12815,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { source: "custom-api.yaml".to_string(), profile: Some(ProviderProfile { @@ -12873,6 +12896,7 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, @@ -12916,6 +12940,7 @@ mod tests { handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, @@ -13564,6 +13589,7 @@ mod tests { let skipped = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { + request_id: String::new(), name: sandbox_name.to_string(), include_security_flagged: false, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13594,6 +13620,7 @@ mod tests { let approved = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { + request_id: String::new(), name: sandbox_name.to_string(), include_security_flagged: true, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13672,6 +13699,7 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { + request_id: String::new(), name: sandbox_name.to_string(), chunk_id: chunk_id.clone(), proposed_rule: Some(private_rule), @@ -13965,6 +13993,7 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { + request_id: String::new(), name: sandbox_name.to_string(), chunk_id: chunk_id.clone(), proposed_rule: Some(finding_rule), @@ -14122,6 +14151,7 @@ mod tests { let approve = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -14174,6 +14204,7 @@ mod tests { let undo = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -14240,6 +14271,7 @@ mod tests { let cleared = handle_clear_draft_chunks( &state, authed_request(ClearDraftChunksRequest { + request_id: String::new(), name: sandbox_name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -14345,6 +14377,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), @@ -15144,6 +15177,7 @@ mod tests { let error = handle_approve_draft_chunk( &state, with_user(Request::new(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -16551,6 +16585,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -16914,6 +16949,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), @@ -17383,6 +17419,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), @@ -17397,6 +17434,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17411,6 +17449,7 @@ mod tests { handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17554,6 +17593,7 @@ mod tests { let approve_err = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17569,6 +17609,7 @@ mod tests { let reject_err = handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), @@ -17584,6 +17625,7 @@ mod tests { let edit_err = handle_edit_draft_chunk( &state, authed_request(EditDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), @@ -17599,6 +17641,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17613,6 +17656,7 @@ mod tests { let undo_err = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: other_name, chunk_id, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -19705,6 +19749,7 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -19803,6 +19848,7 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "annotated-backfill".to_string(), policy: Some(ProtoSandboxPolicy::default()), setting_key: String::new(), @@ -20835,6 +20881,7 @@ mod tests { let err = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -20936,6 +20983,7 @@ mod tests { handle_update_config( &state_clone, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5033c388a..f2a193e109 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -50,7 +50,7 @@ const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKL /// response. Key names are preserved so callers can display credential counts /// and key listings. Internal server paths (sandbox env /// injection) read credentials from the store directly and are unaffected. -fn redact_provider_credentials(mut provider: Provider) -> Provider { +pub(super) fn redact_provider_credentials(mut provider: Provider) -> Provider { for value in provider.credentials.values_mut() { *value = "REDACTED".to_string(); } @@ -2689,6 +2689,7 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( state, @@ -2758,6 +2759,7 @@ pub(super) async fn handle_import_provider_profiles( if let Some(metadata) = stored.metadata.as_mut() { metadata.resource_version = result.resource_version; } + replay_facts.resource(&stored)?; let resource_version = stored_profile_resource_version(&stored); imported.push(profile_response_payload( stored.profile.unwrap_or_default(), @@ -2777,6 +2779,7 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( state, @@ -2891,6 +2894,7 @@ pub(super) async fn handle_update_provider_profiles( if let Some(metadata) = stored.metadata.as_mut() { metadata.resource_version = result.resource_version; } + replay_facts.resource(&stored)?; let resource_version = stored_profile_resource_version(&stored); let profile = profile_response_payload(stored.profile.unwrap_or_default(), resource_version); @@ -4331,6 +4335,7 @@ pub(super) async fn handle_configure_provider_refresh( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -4729,6 +4734,7 @@ pub(super) async fn handle_configure_provider_refresh( .await?; } + replay_facts.refresh(&state_record)?; Ok(Response::new(ConfigureProviderRefreshResponse { status: Some(crate::provider_refresh::refresh_status_from_state( &state_record, @@ -4741,6 +4747,7 @@ pub(super) async fn handle_rotate_provider_credential( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -4771,6 +4778,7 @@ pub(super) async fn handle_rotate_provider_credential( ) .await?; + replay_facts.refresh(&refresh_state)?; Ok(Response::new(RotateProviderCredentialResponse { status: Some(crate::provider_refresh::refresh_status_from_state( &refresh_state, @@ -5220,6 +5228,7 @@ mod tests { handle_import_provider_profiles( state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -5360,6 +5369,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "grant-new.yaml".to_string(), @@ -5388,6 +5398,7 @@ mod tests { handle_import_provider_profiles( &task_state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), @@ -5441,6 +5452,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(updated_profile.clone()), source: "custom-api.yaml".to_string(), @@ -5486,6 +5498,7 @@ mod tests { let built_in = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -5508,6 +5521,7 @@ mod tests { let missing = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("missing-custom")), source: "missing-custom.yaml".to_string(), @@ -5540,6 +5554,7 @@ mod tests { let missing_version = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -5563,6 +5578,7 @@ mod tests { let stale_error = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(stale_profile), source: "custom-api.yaml".to_string(), @@ -5612,6 +5628,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(edited_payload), source: "profile-a.yaml".to_string(), @@ -5696,6 +5713,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(profile), source: "grant-updated.yaml".to_string(), @@ -5880,6 +5898,7 @@ mod tests { handle_import_provider_profiles( state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -6108,6 +6127,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -6179,6 +6199,7 @@ mod tests { source: "duration-api.proto".to_string(), }], workspace: "default".to_string(), + request_id: String::new(), }), ) .await @@ -6234,6 +6255,7 @@ mod tests { source: "invalid-duration-api.proto".to_string(), }], workspace: "default".to_string(), + request_id: String::new(), }), ) .await @@ -6260,6 +6282,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(initial_profile), source: "fanout.yaml".to_string(), @@ -6328,6 +6351,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(conflicting_profile), source: "fanout.yaml".to_string(), @@ -6368,6 +6392,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -6396,6 +6421,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), @@ -6431,6 +6457,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile(" alex-api ")), @@ -6469,6 +6496,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), @@ -6496,6 +6524,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: " Alex-API ".to_string(), workspace: "default".to_string(), @@ -6517,6 +6546,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile("bulk-one")), @@ -6566,6 +6596,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "advanced-api".to_string(), @@ -6693,6 +6724,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-lint")), source: "scoped-lint.yaml".to_string(), @@ -6766,6 +6798,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -6779,6 +6812,7 @@ mod tests { let builtin_err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "github".to_string(), workspace: "default".to_string(), @@ -6820,6 +6854,7 @@ mod tests { let in_use_err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "custom-api".to_string(), workspace: "default".to_string(), @@ -6833,6 +6868,7 @@ mod tests { let attached = super::super::sandbox::handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "sandbox-custom".to_string(), provider_name: "custom-provider".to_string(), expected_resource_version: 0, @@ -6872,6 +6908,7 @@ mod tests { let err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "global-custom".to_string(), workspace: String::new(), @@ -6921,6 +6958,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7016,6 +7054,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7088,6 +7127,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), @@ -7154,6 +7194,7 @@ mod tests { .await .unwrap(); let request = |client_secret: &str| ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "configure-conflict".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7276,6 +7317,7 @@ mod tests { first_state.credentials.clone(), )); let request = |client_secret: &str| ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "configure-create-race".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7407,6 +7449,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7491,6 +7534,7 @@ mod tests { handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), @@ -7550,6 +7594,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "vertex-sa".to_string(), credential_key: "GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, @@ -7623,6 +7668,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7674,6 +7720,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), @@ -7736,6 +7783,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7795,6 +7843,7 @@ mod tests { handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), @@ -7986,6 +8035,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "refreshing-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -8068,6 +8118,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "first-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -8089,6 +8140,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "second-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -8150,6 +8202,7 @@ mod tests { let endpoint_override = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -8177,6 +8230,7 @@ mod tests { let missing_material = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -8233,6 +8287,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: strategy as i32, @@ -8266,6 +8321,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -8279,6 +8335,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "custom-api".to_string(), workspace: "default".to_string(), @@ -8319,6 +8376,7 @@ mod tests { handle_delete_provider_profile( &task_state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "guarded-delete".to_string(), workspace: "default".to_string(), @@ -8352,6 +8410,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("guarded-create")), source: "guarded-create.yaml".to_string(), @@ -8371,6 +8430,7 @@ mod tests { handle_create_provider( &task_state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8846,6 +8906,7 @@ mod tests { let err = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_handle( "openai-ref", "openai", @@ -8869,6 +8930,7 @@ mod tests { let err = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_values("legacy-gitlab", "gitlab")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8891,6 +8953,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "pypi".to_string(), @@ -8922,6 +8985,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("gitlab")), source: "custom-gitlab.yaml".to_string(), @@ -8935,6 +8999,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "private-gitlab".to_string(), @@ -8982,6 +9047,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "enterprise-github.yaml".to_string(), @@ -8997,6 +9063,7 @@ mod tests { let provider = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "enterprise-github", "gh", @@ -9045,6 +9112,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "openai-local", "openai", @@ -9147,6 +9215,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "provider-profile.yaml".to_string(), @@ -9162,6 +9231,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "exchange", "spiffe-token-exchange-demo", @@ -9200,6 +9270,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_handle( "openai-local", "openai", @@ -9467,6 +9538,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "delegated-refresh-api".to_string(), @@ -9563,6 +9635,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), @@ -9606,6 +9679,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), @@ -10150,6 +10224,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "stable-refresh-provider.yaml".to_string(), @@ -10177,6 +10252,7 @@ mod tests { .await .unwrap(); let configure = || ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "stable-refresh".to_string(), credential_key: "ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -10241,6 +10317,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "stable-refresh".to_string(), @@ -12067,6 +12144,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "profile-backed-openai", "openai", @@ -12097,6 +12175,7 @@ mod tests { let error = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(update), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12118,6 +12197,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "required-openai", "openai", @@ -12147,6 +12227,7 @@ mod tests { let error = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(update), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12173,6 +12254,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -12203,6 +12285,7 @@ mod tests { let response = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(updated_provider.clone()), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12244,6 +12327,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -12274,6 +12358,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(stale_provider), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12322,6 +12407,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "openai-local", "openai", @@ -12352,6 +12438,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(stale_provider), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12396,6 +12483,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -12429,6 +12517,7 @@ mod tests { handle_update_provider( &state_clone, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(updated), credential_expiration_times: HashMap::new(), clear_credential_expiration_keys: Vec::new(), @@ -12519,6 +12608,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "my-aws-v2".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12577,6 +12667,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-endpoint-override".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12656,6 +12747,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-partial-source".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12711,6 +12803,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-lone-session".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12769,6 +12862,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-outputs".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12849,6 +12943,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-update-guard".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12875,6 +12970,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "aws-update-guard".to_string(), @@ -12944,6 +13040,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "generic-no-profile".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12998,6 +13095,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-wrong-key".to_string(), credential_key: "AWS_SECRET_ACCESS_KEY".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -13178,6 +13276,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "new-aws-provider".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -13255,6 +13354,7 @@ mod tests { let configure = |provider: &str| { authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: provider.to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -13461,6 +13561,7 @@ mod tests { let created_default = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13493,6 +13594,7 @@ mod tests { let created_beta = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13590,6 +13692,7 @@ mod tests { let deleted = handle_delete_provider( &state, authed_request(DeleteProviderRequest { + request_id: String::new(), allow_missing: false, name: "shared-name".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13639,6 +13742,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13711,6 +13815,7 @@ mod tests { let import_error = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), workspace: String::new(), profiles: Vec::new(), }), @@ -13727,6 +13832,7 @@ mod tests { let update_error = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), id: "nonexistent".to_string(), workspace: String::new(), ..UpdateProviderProfilesRequest::default() @@ -13760,6 +13866,7 @@ mod tests { let delete_error = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "nonexistent".to_string(), workspace: String::new(), @@ -13952,6 +14059,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-custom")), source: "ws-custom.yaml".to_string(), @@ -14005,6 +14113,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -14082,6 +14191,7 @@ mod tests { handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id, workspace, @@ -14115,6 +14225,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -14152,6 +14263,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-api")), source: "scoped-api.yaml".to_string(), @@ -14197,6 +14309,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("platform-only")), source: "platform-only.yaml".to_string(), @@ -14234,6 +14347,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-target")), source: "shadow-target.yaml".to_string(), @@ -14249,6 +14363,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "shadow-target.yaml".to_string(), @@ -14282,6 +14397,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -14295,6 +14411,7 @@ mod tests { let resp = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -14324,6 +14441,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("global-only")), source: "global-only.yaml".to_string(), @@ -14337,6 +14455,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-only")), source: "ws-only.yaml".to_string(), @@ -14378,6 +14497,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test.yaml".to_string(), @@ -14393,6 +14513,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test.yaml".to_string(), @@ -14427,6 +14548,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test-ws.yaml".to_string(), @@ -14442,6 +14564,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test-ws.yaml".to_string(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 05e525b5c0..ea470a7408 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3282,6 +3282,7 @@ mod tests { #[test] fn sandbox_create_telemetry_uses_resolved_template_gpu_request() { let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], policy: Some(openshell_core::proto::SandboxPolicy::default()), @@ -3321,6 +3322,7 @@ mod tests { #[test] fn sandbox_create_telemetry_falls_back_to_request_for_unresolved_template() { let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], ..SandboxSpec::default() @@ -3758,6 +3760,7 @@ mod tests { handle_delete_sandbox_inner( &delete_state, authed_request(DeleteSandboxRequest { + request_id: String::new(), allow_missing: false, name: "reused-name".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -3821,6 +3824,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3862,6 +3866,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3900,6 +3905,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3951,6 +3957,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3976,6 +3983,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -4023,6 +4031,7 @@ mod tests { let error = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-gcp".to_string(), expected_resource_version: 0, @@ -4091,6 +4100,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, @@ -4262,6 +4272,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "collision".to_string(), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -4290,6 +4301,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "user-catalog".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -4324,6 +4336,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "reserved-policy-key".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4691,6 +4704,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "annotated".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -4749,6 +4763,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "partial-id".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4814,6 +4829,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "kube-partial-id".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4849,6 +4865,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-label".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), @@ -4880,6 +4897,7 @@ mod tests { handle_create_sandbox( &task_state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "guarded-create".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], @@ -5466,6 +5484,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], @@ -5559,6 +5578,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5611,6 +5631,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5650,6 +5671,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-corrupt".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5687,6 +5709,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec { environment: HashMap::from([("INLINE".to_string(), "blocked".to_string())]), @@ -5715,6 +5738,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5740,6 +5764,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec { providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), @@ -5768,6 +5793,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-direct-create".to_string(), spec: Some(SandboxSpec { providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), @@ -5811,6 +5837,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, @@ -5858,6 +5885,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, @@ -5913,6 +5941,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, @@ -5960,6 +5989,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -5987,6 +6017,7 @@ mod tests { let err = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -6199,6 +6230,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -6251,6 +6283,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -6314,6 +6347,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -6366,6 +6400,7 @@ mod tests { let err = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -6447,6 +6482,7 @@ mod tests { handle_attach_sandbox_provider( &state_clone, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, @@ -6828,6 +6864,7 @@ mod tests { let err = handle_delete_sandbox( &state, non_member_request(DeleteSandboxRequest { + request_id: String::new(), allow_missing: false, workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), @@ -6845,6 +6882,7 @@ mod tests { handle_stop_sandbox( &state, non_member_request(StopSandboxRequest { + request_id: String::new(), workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), }), @@ -6853,6 +6891,7 @@ mod tests { handle_start_sandbox( &state, non_member_request(StartSandboxRequest { + request_id: String::new(), workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), }), diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 328c693004..544eb4f5d0 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -434,6 +434,7 @@ mod tests { let exposed = handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -486,6 +487,7 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: "my-sandbox".to_string(), service: "web".to_string(), @@ -544,6 +546,7 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -561,6 +564,7 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -612,6 +616,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 7070, @@ -630,6 +635,7 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -647,6 +653,7 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -736,6 +743,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -751,6 +759,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -840,6 +849,7 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: "my-sandbox".to_string(), service: "web".to_string(), @@ -892,6 +902,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "api".to_string(), target_port: 3000, diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index f112522558..ac31017880 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -447,11 +447,14 @@ where let context = gateway_interceptor_context(req.extensions()); let principal = req.extensions().get::().cloned(); - let (parts, body) = req.into_parts(); + let (mut parts, body) = req.into_parts(); let mut body = match collect_intercepted_grpc_body(body).await { Ok(body) => body, Err(status) => return Ok(status.into_http()), }; + // Retain only in memory. evaluate_request below validates the single + // uncompressed frame before this extension reaches typed dispatch. + let original_body = body.clone(); if let Some(state) = state.as_ref() { body = match hydrate_update_provider_identity(&path, body, state, principal.as_ref()) @@ -467,6 +470,12 @@ where Err(status) => return Ok(status.into_http()), }; + parts + .extensions + .insert(crate::grpc::mutation_replay::OriginalMutation( + original_body[GRPC_FRAME_HEADER_LEN..].to_vec(), + )); + let req = Request::from_parts( parts, boxed_body_from_bytes(Bytes::from(intercepted.body.clone())), @@ -474,6 +483,10 @@ where let response = inner.ready().await?.call(req).await?; if grpc_status_from_response(&response) != "0" + || response + .headers() + .get("openshell-replayed") + .is_some_and(|value| value == "true") || !interceptors.has_post_commit(&intercepted) { return Ok(response); @@ -1854,6 +1867,196 @@ mod tests { interceptor_task.abort(); } + #[derive(Clone, Default)] + struct ReplayTestInterceptor { + modifications: Arc, + validations: Arc, + observations: Arc, + deny: Arc, + name: Arc>, + } + + #[tonic::async_trait] + impl GatewayInterceptor for ReplayTestInterceptor { + async fn describe( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let mut manifest = PostCommitTestInterceptor + .describe(request) + .await? + .into_inner(); + manifest.bindings.push(InterceptorBinding { + id: "validate-create".into(), + selector: manifest.bindings[0].selector.clone(), + phases: vec![ + GatewayInterceptorPhase::ModifyOperation.into(), + GatewayInterceptorPhase::Validate.into(), + ], + failure_policy: "fail_closed".into(), + }); + Ok(tonic::Response::new(manifest)) + } + + async fn evaluate( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + use openshell_core::proto::gateway_interceptor::v1::{ + JsonPatch, interceptor_evaluation::Phase, + }; + let mut result = InterceptorResult { + allowed: true, + ..Default::default() + }; + match request.into_inner().phase.unwrap() { + Phase::ModifyOperation(_) => { + self.modifications.fetch_add(1, Ordering::SeqCst); + result.patches.push(JsonPatch { + op: "replace".into(), + path: "/name".into(), + value: Some(prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue( + self.name.lock().unwrap().clone(), + )), + }), + ..Default::default() + }); + } + Phase::Validate(_) => { + self.validations.fetch_add(1, Ordering::SeqCst); + result.allowed = !self.deny.load(Ordering::SeqCst); + result.reason = "current policy denies creation".into(); + result.status_code = "PERMISSION_DENIED".into(); + } + Phase::PostCommit(_) => { + self.observations.fetch_add(1, Ordering::SeqCst); + } + } + Ok(tonic::Response::new(result)) + } + + async fn snapshot_provider_profiles( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + PostCommitTestInterceptor + .snapshot_provider_profiles(request) + .await + } + } + + #[tokio::test] + async fn mutation_replay_revalidates_interceptors_and_observes_commit_once() { + use openshell_core::proto::{ + SandboxResponse, SandboxSpec, open_shell_server::OpenShellServer, + }; + let interceptor = ReplayTestInterceptor::default(); + *interceptor.name.lock().unwrap() = "intercepted-create".into(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let service = interceptor.clone(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "post-commit-test".into(), + grpc_endpoint: format!("http://{address}"), + ..Default::default() + }]) + .await + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let key = directory.path().join("key"); + std::fs::write(&key, b"test-only-private-key").unwrap(); + let mut state = crate::grpc::test_support::test_server_state().await; + Arc::get_mut(&mut state).unwrap().config.gateway_jwt = + Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); + let inner = OpenShellServer::new(OpenShellService::new(state.clone())); + let mut service = GatewayInterceptorGrpcService::new(inner, runtime, Some(state)); + let input = CreateSandboxRequest { + name: "client-original".into(), + request_id: uuid::Uuid::new_v4().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + spec: Some(SandboxSpec::default()), + ..Default::default() + }; + let request = || { + let principal = crate::grpc::test_support::authed_request(()) + .extensions() + .get::() + .unwrap() + .clone(); + Request::builder() + .uri("/openshell.v1.OpenShell/CreateSandbox") + .header("content-type", "application/grpc") + .header("openshell-replayed", "true") + .extension(principal) + .body(boxed_body_from_bytes(grpc_frame(&input.encode_to_vec()))) + .unwrap() + }; + let first = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert!(!first.headers().contains_key("openshell-replayed")); + let first = first.into_body().collect().await.unwrap(); + assert_eq!(first.trailers().unwrap().get("grpc-status").unwrap(), "0"); + let first = decode_unary_grpc_message::(&first.to_bytes()).unwrap(); + assert_eq!( + first + .sandbox + .as_ref() + .unwrap() + .metadata + .as_ref() + .unwrap() + .name, + "intercepted-create" + ); + let replay = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert_eq!(replay.headers().get("openshell-replayed").unwrap(), "true"); + let replay = replay.into_body().collect().await.unwrap(); + assert_eq!( + decode_unary_grpc_message::(&replay.to_bytes()).unwrap(), + first + ); + assert_eq!(interceptor.modifications.load(Ordering::SeqCst), 2); + assert_eq!(interceptor.validations.load(Ordering::SeqCst), 2); + assert_eq!(interceptor.observations.load(Ordering::SeqCst), 1); + interceptor.deny.store(true, Ordering::SeqCst); + let denied = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert_eq!(denied.headers().get("grpc-status").unwrap(), "7"); + assert_eq!(interceptor.validations.load(Ordering::SeqCst), 3); + assert_eq!(interceptor.observations.load(Ordering::SeqCst), 1); + task.abort(); + } + #[test] fn grpc_trailer_status_takes_precedence_over_headers() { let response = Response::builder() diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index a6ebf3f9ab..5d168322e4 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,7 +118,7 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "777946305230845c6043e0fde992d2a16ac323ee9fce3e1a0c29b6652adbe1d6"; + "1c72f65167a5ceb00cd17043079b92324ca55d19b547c86827b51bd7376cb23c"; const DURABLE_SCHEMA_SHA256: &str = "65066c0b0eef57a4c708f20fcbbb8e8f47376da9f4bf73dfc3bca0b3df174ba8"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 3d451e13c8..23705a24e9 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -819,6 +819,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) } let req = openshell_core::proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: true, name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), @@ -1478,6 +1479,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { }; let req = openshell_core::proto::CreateSandboxRequest { + request_id: String::new(), name, spec: Some(openshell_core::proto::SandboxSpec { providers: selected_providers, @@ -1752,6 +1754,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }; let req = openshell_core::proto::CreateProviderRequest { + request_id: String::new(), provider: Some(openshell_core::proto::Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1869,6 +1872,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { } let req = openshell_core::proto::UpdateProviderRequest { + request_id: String::new(), provider: Some(openshell_core::proto::Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1919,6 +1923,7 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name, workspace_scope: Some(named_workspace_scope(workspace)), @@ -1971,6 +1976,7 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::ApproveDraftChunkRequest { + request_id: String::new(), name, chunk_id, workspace_scope: Some(named_workspace_scope(workspace)), @@ -2016,6 +2022,7 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { + request_id: String::new(), name, chunk_id, reason: String::new(), @@ -2066,6 +2073,7 @@ fn spawn_draft_approve_all( }) .collect(); let req = openshell_core::proto::ApproveAllDraftChunksRequest { + request_id: String::new(), name, include_security_flagged: false, workspace_scope: Some(named_workspace_scope(workspace)), diff --git a/docs/reference/api-errors.mdx b/docs/reference/api-errors.mdx index 5266913bbc..eaa6b99575 100644 --- a/docs/reference/api-errors.mdx +++ b/docs/reference/api-errors.mdx @@ -33,7 +33,7 @@ Recognized gateway reasons include the following. | `PROFILE_SOURCE_UNAVAILABLE` | `UNAVAILABLE` | Retry a profile snapshot read after at least the supplied delay. | | `REQUEST_ID_PAYLOAD_MISMATCH` | `FAILED_PRECONDITION` | Keep the original payload for that request ID. Inspect the original operation before submitting a different one. | | `REQUEST_OUTCOME_UNCERTAIN` | `FAILED_PRECONDITION` | An attempt is admitted but has no confirmed replayable success. Observe resource state and reconcile effects. Do not switch to a new ID to bypass the admission. | -| `REQUEST_REPLAY_UNAVAILABLE` | `FAILED_PRECONDITION` | The original workspace or created resource no longer exists at its recorded version. The gateway does not execute the request again. | +| `REQUEST_REPLAY_UNAVAILABLE` | `FAILED_PRECONDITION` | The original scope, resource, interceptor transformation, or fingerprint key is no longer replayable. Reconcile effects; the gateway does not execute the request again. Missing private-key material can also reject admission before work starts. | ## Status and retry guidance @@ -90,13 +90,18 @@ No SDK automatically retries a mutation as a result of decoding these details. ## Durable request admission -Six unary RPCs accept an optional `request_id` in their protobuf requests. +Thirty user-callable unary RPCs accept an optional `request_id` in their protobuf requests. | Resource | RPCs | |---|---| | Workspace | `CreateWorkspace`, `DeleteWorkspace` | | Workspace member | `AddWorkspaceMember`, `RemoveWorkspaceMember` | | Sandbox template | `CreateSandboxTemplate`, `DeleteSandboxTemplate` | +| Sandbox | `CreateSandbox`, `DeleteSandbox`, `StartSandbox`, `StopSandbox`, `AttachSandboxProvider`, `DetachSandboxProvider` | +| Service | `ExposeService`, `DeleteService` | +| Provider | `CreateProvider`, `UpdateProvider`, `DeleteProvider`, `ConfigureProviderRefresh`, `DeleteProviderRefresh`, `RotateProviderCredential` | +| Provider profile | `ImportProviderProfiles`, `UpdateProviderProfiles`, `DeleteProviderProfile` | +| Policy and config | `UpdateConfig`, `ApproveDraftChunk`, `RejectDraftChunk`, `EditDraftChunk`, `UndoDraftChunk`, `ApproveAllDraftChunks`, `ClearDraftChunks` | Use a nonzero, hyphenated UUID of exactly 36 characters. Uppercase and lowercase forms identify the same request. An empty ID preserves the existing behavior @@ -121,14 +126,44 @@ execution. Do not retry an old operation after that window without reconciling its effects. Unresolved admissions never expire. Every replay checks current authorization, including membership and permission -to assign workspace administrators. Create replays load the original resource -by its UUID and require its recorded resource version. Deletion or modification -of that resource makes replay unavailable, even within 24 hours. A replacement -workspace also makes workspace-scoped replay unavailable. Delete replays return -the recorded outcome without deleting a same-name replacement. - -Replay rows contain fingerprints, resource references, and deletion outcomes, -not copies of template contents or credential-bearing responses. Each caller can +to assign workspace administrators. A replacement workspace makes workspace-scoped +replay unavailable. Target readiness, conditional-write versions, and draft review +tokens are first-execution preconditions, not conditions to execute again. + +| Result family | Replay behavior | +|---|---| +| Workspace, membership, template, provider, and profile resources | Load the original UUID at its recorded version. Missing or changed resources make replay unavailable. Provider credentials remain redacted. | +| Sandbox resources | Load the original UUID with its current state, including newer status or configuration. Attach/detach flags describe the original operation. A missing original sandbox makes replay unavailable. | +| Service endpoint | Require the original endpoint version and sandbox identity. Return the recorded URL. | +| Refresh status | Return current status for the original provider, refresh record, and authorization epoch. Reconfiguration or removal makes replay unavailable. Rotation is not repeated. | +| Policy and config | Return the original versions, counts, and nonsecret annotations while the original sandbox exists. Global config has no sandbox guard. | +| Deletion | Return the original outcome without requiring the deleted target or parent to exist and without deleting a replacement. `ACCEPTED` remains an acknowledgment of the original cleanup, not proof of completion. | + +Profile imports and updates also preserve their original public diagnostics and +success flags. Profile declarations, diagnostic source labels, and annotations +must not contain secrets. + +For sandbox, service, provider, profile, and policy/config methods, fingerprints +use HMAC with domain-separated material derived from the configured gateway JWT +private key, or the primary TLS private key when JWT signing is not configured. +These methods require readable, stable private-key material when you supply an +ID. Replicas sharing a database must share that material. Changing it makes +existing protected receipts unavailable until their successful replay window +expires; unresolved admissions remain protected indefinitely. Workspace, +membership, and template methods retain their original fingerprint format. + +Gateway interceptors run their current modification and validation phases on +every attempt, including replay. They cannot add, remove, or change `request_id`. +The gateway fingerprints the original client payload and separately guards the +effective transformed payload and workspace. A changed transformation makes +replay unavailable. A current denial remains authoritative. Successful replay +does not invoke post-commit observers again. Post-commit observation remains +best-effort; this is not a durable delivery queue. + +Replay rows contain fingerprints, resource references, and explicitly selected +public diagnostic or scalar outcomes, not credential-bearing response snapshots. +Completed receipts are limited to 64 KiB. A failure to capture a bounded receipt +after execution leaves the admission unresolved. Each caller can hold at most 1,000 durable admissions. At capacity, the gateway removes expired successes; if no space remains, it rejects new admissions with `RESOURCE_EXHAUSTED`. Existing records remain protected. Unresolved records @@ -137,9 +172,10 @@ The gateway also limits detached admission workers to 64 per process and these request messages to 4 MiB. Supply IDs through generated/raw RPC clients. Curated SDK request-ID helpers and -automatic retry policies are not part of this contract yet. Other mutations, -including sandbox creation, provider writes, exec, SSH sessions, and rootfs -staging, do not gain deduplication from this feature. Check gateway compatibility +automatic retry policies are not part of this contract yet. Exec, SSH sessions, +rootfs staging, and supervisor-authenticated mutations do not gain deduplication +from this feature. Sandbox principals cannot opt into `UpdateConfig` admission. +Check gateway compatibility before relying on IDs; older protobuf servers can silently ignore unknown fields. ## Deletion outcomes diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 06c1d325fc..9b48152961 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -3,11 +3,13 @@ from __future__ import annotations +import contextlib import threading +import uuid from typing import TYPE_CHECKING from google.protobuf import duration_pb2 -from openshell._proto import openshell_pb2 +from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -15,6 +17,71 @@ from openshell import Sandbox, SandboxClient, WorkspaceClient +def test_mutation_replay_preserves_sandbox_lifecycle_and_replacement( + sandbox_client: SandboxClient, +) -> None: + name = f"replay-{uuid.uuid4().hex[:8]}" + scope = datamodel_pb2.WorkspaceSelector(workspace="default") + stub = sandbox_client._stub + create = openshell_pb2.CreateSandboxRequest( + name=name, + spec=openshell_pb2.SandboxSpec(), + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + + def replay(method, request): + result, call = method.with_call(request, timeout=60) + assert dict(call.initial_metadata())["openshell-replayed"] == "true" + return result + + try: + original = stub.CreateSandbox(create, timeout=60).sandbox.metadata.id + sandbox_client.wait_ready(name, workspace="default", timeout_seconds=300) + assert replay(stub.CreateSandbox, create).sandbox.metadata.id == original + stop = openshell_pb2.StopSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + stub.StopSandbox(stop, timeout=60) + sandbox_client.wait_stopped(name, workspace="default", timeout_seconds=120) + assert replay(stub.StopSandbox, stop).sandbox.metadata.id == original + start = openshell_pb2.StartSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + stub.StartSandbox(start, timeout=60) + sandbox_client.wait_ready(name, workspace="default", timeout_seconds=300) + assert replay(stub.StartSandbox, start).sandbox.metadata.id == original + update = openshell_pb2.UpdateConfigRequest( + name=name, + workspace_scope=scope, + setting_key="ocsf_json_enabled", + setting_value=sandbox_pb2.SettingValue(bool_value=True), + request_id=str(uuid.uuid4()), + ) + updated = stub.UpdateConfig(update, timeout=30) + assert replay(stub.UpdateConfig, update) == updated + delete = openshell_pb2.DeleteSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + deleted = stub.DeleteSandbox(delete, timeout=60) + assert deleted.sandbox_id == original + sandbox_client.wait_deleted(name, workspace="default", timeout_seconds=120) + replacement = sandbox_client.create(workspace="default", name=name) + assert replacement.id != original + assert replay(stub.DeleteSandbox, delete) == deleted + assert sandbox_client.get(name, workspace="default").id == replacement.id + finally: + with contextlib.suppress(Exception): + sandbox_client.delete(name, workspace="default", allow_missing=True) + sandbox_client.wait_deleted(name, workspace="default", timeout_seconds=120) + + def test_sandbox_api_crud_and_exec( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -133,7 +200,7 @@ def requests(): def test_list_scoped_and_for_all_workspaces( sandbox_client: SandboxClient, - workspace_client: "WorkspaceClient", + workspace_client: WorkspaceClient, ) -> None: import contextlib import uuid @@ -151,9 +218,7 @@ def test_list_scoped_and_for_all_workspaces( ) created_default.append(ref_default.name) - ref_other = sandbox_client.create( - workspace=other_ws, name=f"ls-oth-{suffix}" - ) + ref_other = sandbox_client.create(workspace=other_ws, name=f"ls-oth-{suffix}") created_other.append(ref_other.name) default_ids = set(sandbox_client.list_ids(workspace="default")) @@ -207,15 +272,29 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: # Labels round-trip through create and get. assert ref_a.labels["role"] == "primary" - assert dict(sandbox_client.get(job_a, workspace="default").labels)["role"] == "primary" - assert dict(sandbox_client.get(job_b, workspace="default").labels)["role"] == "secondary" + assert ( + dict(sandbox_client.get(job_a, workspace="default").labels)["role"] + == "primary" + ) + assert ( + dict(sandbox_client.get(job_b, workspace="default").labels)["role"] + == "secondary" + ) # A specific selector filters to exactly the primary sandbox. assert { - s.name for s in sandbox_client.list_all(workspace="default", label_selector=primary_selector) + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=primary_selector + ) } == {job_a} # The shared group label returns both. - assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { + assert { + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) + } == { job_a, job_b, } @@ -224,15 +303,20 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: assert sandbox_client.delete(job_a, workspace="default") sandbox_client.wait_deleted(job_a, workspace="default") created.remove(job_a) - assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { - job_b - } + assert { + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) + } == {job_b} # Final deletion leaves no matching sandboxes. assert sandbox_client.delete(job_b, workspace="default") sandbox_client.wait_deleted(job_b, workspace="default") created.remove(job_b) - assert not sandbox_client.list_all(workspace="default", label_selector=group_selector) + assert not sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) finally: for name in created: with contextlib.suppress(Exception): diff --git a/proto/openshell.proto b/proto/openshell.proto index 3319bc61a8..0e9fe429a6 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1161,6 +1161,9 @@ message CreateSandboxRequest { string workload_template_name = 7; // Explicit workspace for the sandbox. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 9; } message CreateSandboxTemplateRequest { @@ -1308,6 +1311,9 @@ message AttachSandboxProviderRequest { uint64 expected_resource_version = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Detach provider from sandbox request. @@ -1325,6 +1331,9 @@ message DetachSandboxProviderRequest { uint64 expected_resource_version = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Delete sandbox request. @@ -1338,6 +1347,9 @@ message DeleteSandboxRequest { // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. bool allow_missing = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Stop sandbox request. @@ -1348,6 +1360,9 @@ message StopSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Start sandbox request. @@ -1358,6 +1373,9 @@ message StartSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Sandbox response. @@ -1457,6 +1475,9 @@ message ExposeServiceRequest { bool domain = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 7; } // Request to fetch an exposed sandbox service endpoint. @@ -1505,6 +1526,9 @@ message DeleteServiceRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; bool allow_missing = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Response for deleting an exposed sandbox service endpoint. @@ -1758,6 +1782,9 @@ message CreateProviderRequest { openshell.datamodel.v1.Provider provider = 1; // Explicit workspace for the provider. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Get provider request. @@ -1798,6 +1825,9 @@ message UpdateProviderRequest { repeated string clear_credential_expiration_keys = 103; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Delete provider request. @@ -1808,6 +1838,9 @@ message DeleteProviderRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; bool allow_missing = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Provider response. @@ -2049,6 +2082,9 @@ message ConfigureProviderRefreshRequest { google.protobuf.Timestamp expiration_time = 106; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 9; } message ConfigureProviderRefreshResponse { @@ -2062,6 +2098,9 @@ message RotateProviderCredentialRequest { string credential_key = 2; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } message RotateProviderCredentialResponse { @@ -2076,6 +2115,9 @@ message DeleteProviderRefreshRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; bool allow_missing = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message DeleteProviderRefreshResponse { @@ -2139,6 +2181,9 @@ message ImportProviderProfilesRequest { // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). // When empty, profiles are platform-scoped (Platform Admin). string workspace = 2; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 3; } // Import custom provider profiles response. @@ -2161,6 +2206,9 @@ message UpdateProviderProfilesRequest { // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. string workspace = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Update one custom provider profile response. @@ -2198,6 +2246,9 @@ message DeleteProviderProfileRequest { // targets platform-scoped profile. string workspace = 2; bool allow_missing = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Delete custom provider profile response. @@ -2333,6 +2384,9 @@ message UpdateConfigRequest { // Explicit workspace scope for sandbox-scoped updates. Omit only when // `global` is true; the all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 11; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 12; } message PolicyMergeOperation { @@ -2916,6 +2970,9 @@ message ApproveDraftChunkRequest { string review_token = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message ApproveDraftChunkResponse { @@ -2937,6 +2994,9 @@ message RejectDraftChunkRequest { string reason = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message RejectDraftChunkResponse {} @@ -2959,6 +3019,9 @@ message ApproveAllDraftChunksRequest { repeated DraftChunkApproval approvals = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message ApproveAllDraftChunksResponse { @@ -2985,6 +3048,9 @@ message EditDraftChunkRequest { openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message EditDraftChunkResponse {} @@ -2999,6 +3065,9 @@ message UndoDraftChunkRequest { string chunk_id = 2; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } message UndoDraftChunkResponse { @@ -3016,6 +3085,9 @@ message ClearDraftChunksRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } message ClearDraftChunksResponse { diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index b00ddc8e60..310df01110 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -2668,8 +2668,11 @@ type CreateSandboxRequest struct { WorkloadTemplateName string `protobuf:"bytes,7,opt,name=workload_template_name,json=workloadTemplateName,proto3" json:"workload_template_name,omitempty"` // Explicit workspace for the sandbox. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,9,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxRequest) Reset() { @@ -2751,6 +2754,13 @@ func (x *CreateSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *CreateSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type CreateSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` @@ -3495,8 +3505,11 @@ type AttachSandboxProviderRequest struct { ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttachSandboxProviderRequest) Reset() { @@ -3557,6 +3570,13 @@ func (x *AttachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *AttachSandboxProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Detach provider from sandbox request. type DetachSandboxProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3571,8 +3591,11 @@ type DetachSandboxProviderRequest struct { ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DetachSandboxProviderRequest) Reset() { @@ -3633,6 +3656,13 @@ func (x *DetachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *DetachSandboxProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete sandbox request. type DeleteSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3642,7 +3672,10 @@ type DeleteSandboxRequest struct { WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. - AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3698,6 +3731,13 @@ func (x *DeleteSandboxRequest) GetAllowMissing() bool { return false } +func (x *DeleteSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Stop sandbox request. type StopSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3705,8 +3745,11 @@ type StopSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StopSandboxRequest) Reset() { @@ -3753,6 +3796,13 @@ func (x *StopSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *StopSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Start sandbox request. type StartSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3760,8 +3810,11 @@ type StartSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartSandboxRequest) Reset() { @@ -3808,6 +3861,13 @@ func (x *StartSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *StartSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Sandbox response. type SandboxResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4284,8 +4344,11 @@ type ExposeServiceRequest struct { Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,7,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExposeServiceRequest) Reset() { @@ -4353,6 +4416,13 @@ func (x *ExposeServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *ExposeServiceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Request to fetch an exposed sandbox service endpoint. type GetServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4556,8 +4626,11 @@ type DeleteServiceRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,5,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteServiceRequest) Reset() { @@ -4618,6 +4691,13 @@ func (x *DeleteServiceRequest) GetAllowMissing() bool { return false } +func (x *DeleteServiceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Response for deleting an exposed sandbox service endpoint. type DeleteServiceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6117,8 +6197,11 @@ type CreateProviderRequest struct { Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` // Explicit workspace for the provider. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateProviderRequest) Reset() { @@ -6165,6 +6248,13 @@ func (x *CreateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *CreateProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Get provider request. type GetProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6297,8 +6387,11 @@ type UpdateProviderRequest struct { ClearCredentialExpirationKeys []string `protobuf:"bytes,103,rep,name=clear_credential_expiration_keys,json=clearCredentialExpirationKeys,proto3" json:"clear_credential_expiration_keys,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateProviderRequest) Reset() { @@ -6359,6 +6452,13 @@ func (x *UpdateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *UpdateProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete provider request. type DeleteProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6366,8 +6466,11 @@ type DeleteProviderRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRequest) Reset() { @@ -6421,6 +6524,13 @@ func (x *DeleteProviderRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Provider response. type ProviderResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -7695,8 +7805,11 @@ type ConfigureProviderRefreshRequest struct { ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,9,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConfigureProviderRefreshRequest) Reset() { @@ -7778,6 +7891,13 @@ func (x *ConfigureProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.Works return nil } +func (x *ConfigureProviderRefreshRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ConfigureProviderRefreshResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -7828,8 +7948,11 @@ type RotateProviderCredentialRequest struct { CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RotateProviderCredentialRequest) Reset() { @@ -7883,6 +8006,13 @@ func (x *RotateProviderCredentialRequest) GetWorkspaceScope() *datamodelv1.Works return nil } +func (x *RotateProviderCredentialRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type RotateProviderCredentialResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -7934,8 +8064,11 @@ type DeleteProviderRefreshRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,5,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRefreshRequest) Reset() { @@ -7996,6 +8129,13 @@ func (x *DeleteProviderRefreshRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderRefreshRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type DeleteProviderRefreshResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Outcome DeletionOutcome `protobuf:"varint,2,opt,name=outcome,proto3,enum=openshell.v1.DeletionOutcome" json:"outcome,omitempty"` @@ -8294,7 +8434,10 @@ type ImportProviderProfilesRequest struct { Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). // When empty, profiles are platform-scoped (Platform Admin). - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8343,6 +8486,13 @@ func (x *ImportProviderProfilesRequest) GetWorkspace() string { return "" } +func (x *ImportProviderProfilesRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Import custom provider profiles response. type ImportProviderProfilesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8417,7 +8567,10 @@ type UpdateProviderProfilesRequest struct { Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` + Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8480,6 +8633,13 @@ func (x *UpdateProviderProfilesRequest) GetWorkspace() string { return "" } +func (x *UpdateProviderProfilesRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Update one custom provider profile response. type UpdateProviderProfilesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8700,8 +8860,11 @@ type DeleteProviderProfileRequest struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8757,6 +8920,13 @@ func (x *DeleteProviderProfileRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderProfileRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete custom provider profile response. type DeleteProviderProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9261,8 +9431,11 @@ type UpdateConfigRequest struct { // Explicit workspace scope for sandbox-scoped updates. Omit only when // `global` is true; the all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,11,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,12,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateConfigRequest) Reset() { @@ -9365,6 +9538,13 @@ func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *UpdateConfigRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type PolicyMergeOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Operation: @@ -12805,8 +12985,11 @@ type ApproveDraftChunkRequest struct { ReviewToken string `protobuf:"bytes,4,opt,name=review_token,json=reviewToken,proto3" json:"review_token,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveDraftChunkRequest) Reset() { @@ -12867,6 +13050,13 @@ func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSel return nil } +func (x *ApproveDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ApproveDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after merge. @@ -12932,8 +13122,11 @@ type RejectDraftChunkRequest struct { Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RejectDraftChunkRequest) Reset() { @@ -12994,6 +13187,13 @@ func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSele return nil } +func (x *RejectDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type RejectDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -13094,8 +13294,11 @@ type ApproveAllDraftChunksRequest struct { Approvals []*DraftChunkApproval `protobuf:"bytes,4,rep,name=approvals,proto3" json:"approvals,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveAllDraftChunksRequest) Reset() { @@ -13156,6 +13359,13 @@ func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *ApproveAllDraftChunksRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ApproveAllDraftChunksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after merge. @@ -13240,8 +13450,11 @@ type EditDraftChunkRequest struct { ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EditDraftChunkRequest) Reset() { @@ -13302,6 +13515,13 @@ func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *EditDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type EditDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -13347,8 +13567,11 @@ type UndoDraftChunkRequest struct { ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UndoDraftChunkRequest) Reset() { @@ -13402,6 +13625,13 @@ func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *UndoDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type UndoDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after removal. @@ -13463,8 +13693,11 @@ type ClearDraftChunksRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClearDraftChunksRequest) Reset() { @@ -13511,6 +13744,13 @@ func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSele return nil } +func (x *ClearDraftChunksRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ClearDraftChunksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Number of chunks cleared. @@ -15093,7 +15333,7 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02R\ftimestamp_ms\"\xd1\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02R\ftimestamp_ms\"\xf0\x04\n" + "\x14CreateSandboxRequest\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + @@ -15101,7 +15341,9 @@ const file_openshell_proto_rawDesc = "" + "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12A\n" + "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x124\n" + "\x16workload_template_name\x18\a \x01(\tR\x14workloadTemplateName\x12R\n" + - "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a9\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\t \x01(\tR\trequestId\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + @@ -15157,27 +15399,37 @@ const file_openshell_proto_rawDesc = "" + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\tworkspaceR\x0eall_workspaces\"\xa5\x01\n" + "\x1bListSandboxProvidersRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x87\x02\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xa6\x02\n" + "\x1cAttachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x87\x02\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xa6\x02\n" + "\x1cDetachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xb4\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xd3\x01\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xac\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8e\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xad\x01\n" + "\x13StartSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"B\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"B\n" + "\x0fSandboxResponse\x12/\n" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"t\n" + "\x15ListSandboxesResponse\x123\n" + @@ -15206,14 +15458,16 @@ const file_openshell_proto_rawDesc = "" + "\fgateway_port\x18\x04 \x01(\rR\vgatewayPort\x12%\n" + "\x0egateway_scheme\x18\x05 \x01(\tR\rgatewayScheme\x120\n" + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12C\n" + - "\x0fexpiration_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\b\x10\tR\rexpires_at_ms\"\xe8\x01\n" + + "\x0fexpiration_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\b\x10\tR\rexpires_at_ms\"\x87\x02\n" + "\x14ExposeServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + "\vtarget_port\x18\x03 \x01(\rR\n" + "targetPort\x12\x16\n" + "\x06domain\x18\x04 \x01(\bR\x06domain\x12R\n" + - "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x05\x10\x06R\tworkspace\"\xac\x01\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\a \x01(\tR\trequestIdJ\x04\b\x05\x10\x06R\tworkspace\"\xac\x01\n" + "\x11GetServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12R\n" + @@ -15226,12 +15480,14 @@ const file_openshell_proto_rawDesc = "" + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\tworkspaceR\x0eall_workspaces\"\x81\x01\n" + "\x14ListServicesResponse\x12A\n" + "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xd4\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xf3\x01\n" + "\x14DeleteServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x05 \x01(\bR\fallowMissingJ\x04\b\x03\x10\x04R\tworkspace\"_\n" + + "\rallow_missing\x18\x05 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"_\n" + "\x15DeleteServiceResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xef\x01\n" + "\x0fServiceEndpoint\x12>\n" + @@ -15344,10 +15600,12 @@ const file_openshell_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x02\x10\x03R\ftimestamp_ms\"0\n" + "\x14SandboxStreamWarning\x12\x18\n" + - "\amessage\x18\x01 \x01(\tR\amessage\"\xba\x01\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"\xd9\x01\n" + "\x15CreateProviderRequest\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12GetProviderRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xcd\x01\n" + @@ -15355,19 +15613,23 @@ const file_openshell_proto_rawDesc = "" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\x92\x04\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\xb1\x04\n" + "\x15UpdateProviderRequest\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12\x82\x01\n" + "\x1bcredential_expiration_times\x18f \x03(\v2B.openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntryR\x19credentialExpirationTimes\x12G\n" + " clear_credential_expiration_keys\x18g \x03(\tR\x1dclearCredentialExpirationKeys\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1ah\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestId\x1ah\n" + "\x1eCredentialExpirationTimesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + - "\x05value\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05value:\x028\x01J\x04\b\x03\x10\x04J\x04\b\x02\x10\x03R\tworkspaceR\x18credential_expires_at_ms\"\xb5\x01\n" + + "\x05value\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05value:\x028\x01J\x04\b\x03\x10\x04J\x04\b\x02\x10\x03R\tworkspaceR\x18credential_expires_at_ms\"\xd4\x01\n" + "\x15DeleteProviderRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"P\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"P\n" + "\x10ProviderResponse\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"\x7f\n" + "\x15ListProvidersResponse\x12>\n" + @@ -15474,7 +15736,7 @@ const file_openshell_proto_rawDesc = "" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"s\n" + " GetProviderRefreshStatusResponse\x12O\n" + - "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xbe\x04\n" + + "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xdd\x04\n" + "\x1fConfigureProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12K\n" + @@ -15482,23 +15744,29 @@ const file_openshell_proto_rawDesc = "" + "\bmaterial\x18\x04 \x03(\v2;.openshell.v1.ConfigureProviderRefreshRequest.MaterialEntryB\x04\x88\xb5\x18\x01R\bmaterial\x120\n" + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12C\n" + "\x0fexpiration_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12R\n" + - "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a;\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\t \x01(\tR\trequestId\x1a;\n" + "\rMaterialEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\a\x10\bJ\x04\b\x06\x10\aR\tworkspaceR\rexpires_at_ms\"i\n" + " ConfigureProviderRefreshResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xc9\x01\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xe8\x01\n" + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"i\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"i\n" + " RotateProviderCredentialResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xeb\x01\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\x8a\x02\n" + "\x1cDeleteProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x05 \x01(\bR\fallowMissingJ\x04\b\x03\x10\x04R\tworkspace\"g\n" + + "\rallow_missing\x18\x05 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"g\n" + "\x1dDeleteProviderRefreshResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xd8\x05\n" + "\x0fProviderProfile\x12\x0e\n" + @@ -15523,19 +15791,23 @@ const file_openshell_proto_rawDesc = "" + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"\x81\x01\n" + "\x1cListProviderProfilesResponse\x129\n" + "\bprofiles\x18\x01 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x82\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa1\x01\n" + "\x1dImportProviderProfilesRequest\x12C\n" + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xc2\x01\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\"\xc2\x01\n" + "\x1eImportProviderProfilesResponse\x12I\n" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x129\n" + "\bprofiles\x18\x02 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12\x1a\n" + - "\bimported\x18\x03 \x01(\bR\bimported\"\xcc\x01\n" + + "\bimported\x18\x03 \x01(\bR\bimported\"\xeb\x01\n" + "\x1dUpdateProviderProfilesRequest\x12A\n" + "\aprofile\x18\x01 \x01(\v2'.openshell.v1.ProviderProfileImportItemR\aprofile\x12:\n" + "\x19expected_resource_version\x18\x02 \x01(\x04R\x17expectedResourceVersion\x12\x0e\n" + "\x02id\x18\x03 \x01(\tR\x02id\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\xbe\x01\n" + + "\tworkspace\x18\x04 \x01(\tR\tworkspace\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestId\"\xbe\x01\n" + "\x1eUpdateProviderProfilesResponse\x12I\n" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x127\n" + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x18\n" + @@ -15547,11 +15819,13 @@ const file_openshell_proto_rawDesc = "" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x12\x14\n" + "\x05valid\x18\x02 \x01(\bR\x05valid\"`\n" + "\x16DeleteProviderResponse\x127\n" + - "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"q\n" + + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\x90\x01\n" + "\x1cDeleteProviderProfileRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tworkspace\x18\x02 \x01(\tR\tworkspace\x12#\n" + - "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\"g\n" + + "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\"g\n" + "\x1dDeleteProviderProfileResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\x94\x01\n" + "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" + @@ -15596,7 +15870,7 @@ const file_openshell_proto_rawDesc = "" + "\rexpires_after\x18f \x01(\v2\x19.google.protobuf.DurationR\fexpiresAfter\x12\x1d\n" + "\n" + "token_type\x18\x03 \x01(\tR\ttokenTypeJ\x04\b\x02\x10\x03R\n" + - "expires_in\"\x95\x05\n" + + "expires_in\"\xb4\x05\n" + "\x13UpdateConfigRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\n" + @@ -15608,7 +15882,9 @@ const file_openshell_proto_rawDesc = "" + "\x10merge_operations\x18\a \x03(\v2\".openshell.v1.PolicyMergeOperationR\x0fmergeOperations\x12:\n" + "\x19expected_resource_version\x18\b \x01(\x04R\x17expectedResourceVersion\x12T\n" + "\vannotations\x18\t \x03(\v22.openshell.v1.UpdateConfigRequest.AnnotationsEntryR\vannotations\x12R\n" + - "\x0fworkspace_scope\x18\v \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a>\n" + + "\x0fworkspace_scope\x18\v \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\f \x01(\tR\trequestId\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + @@ -15878,53 +16154,65 @@ const file_openshell_proto_rawDesc = "" + "\x06chunks\x18\x01 \x03(\v2\x19.openshell.v1.PolicyChunkR\x06chunks\x12'\n" + "\x0frolling_summary\x18\x02 \x01(\tR\x0erollingSummary\x12#\n" + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12H\n" + - "\x12last_analyzed_time\x18h \x01(\v2\x1a.google.protobuf.TimestampR\x10lastAnalyzedTimeJ\x04\b\x04\x10\x05R\x13last_analyzed_at_ms\"\xd1\x01\n" + + "\x12last_analyzed_time\x18h \x01(\v2\x1a.google.protobuf.TimestampR\x10lastAnalyzedTimeJ\x04\b\x04\x10\x05R\x13last_analyzed_at_ms\"\xf0\x01\n" + "\x18ApproveDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12!\n" + "\freview_token\x18\x04 \x01(\tR\vreviewToken\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"c\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"c\n" + "\x19ApproveDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"\xc5\x01\n" + + "policyHash\"\xe4\x01\n" + "\x17RejectDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x16\n" + "\x06reason\x18\x03 \x01(\tR\x06reason\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x1a\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\x1a\n" + "\x18RejectDraftChunkResponse\"R\n" + "\x12DraftChunkApproval\x12\x19\n" + "\bchunk_id\x18\x01 \x01(\tR\achunkId\x12!\n" + - "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\x91\x02\n" + + "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\xb0\x02\n" + "\x1cApproveAllDraftChunksRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\x12>\n" + "\tapprovals\x18\x04 \x03(\v2 .openshell.v1.DraftChunkApprovalR\tapprovals\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"\xb7\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"\xb7\x01\n" + "\x1dApproveAllDraftChunksResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + "policyHash\x12'\n" + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + - "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\xf9\x01\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x98\x02\n" + "\x15EditDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x18\n" + - "\x16EditDraftChunkResponse\"\xab\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\x18\n" + + "\x16EditDraftChunkResponse\"\xca\x01\n" + "\x15UndoDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"`\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"`\n" + "\x16UndoDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"\x92\x01\n" + + "policyHash\"\xb1\x01\n" + "\x17ClearDraftChunksRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"A\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"A\n" + "\x18ClearDraftChunksResponse\x12%\n" + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\"\x91\x01\n" + "\x16GetDraftHistoryRequest\x12\x12\n" + From 6a1c05c13b1a8a358f0d369a67464789ee7c4108 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 15 Sep 2026 11:27:16 -0700 Subject: [PATCH 3/3] fix(api): scope workspace request IDs by target Include the requested workspace name in create/delete admission keys while leaving workspace UUID guards unset. Cover cross-target UUID reuse, replay, and missing targets with server and live gateway regressions. Merge the latest phase-two SDK fixes and preserve the approved interceptor replay changes. Refs #3051. Signed-off-by: Mrunal Patel --- architecture/gateway.md | 3 + .../src/grpc/mutation_replay.rs | 12 +- .../src/grpc/mutation_replay/tests.rs | 141 +++++++++++++++++- docs/reference/api-errors.mdx | 11 ++ e2e/python/test_workspace_api.py | 59 +++++++- 5 files changed, 220 insertions(+), 6 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 673e096756..62eceed3eb 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -478,6 +478,9 @@ Mutation admission uses a private, version-tagged JSON envelope in the same object store. Its identity namespace stays stable across format changes, and an unknown format fails closed. It contains explicit typed receipts, not arbitrary public response payloads, and is not part of the protobuf storage closure. +Workspace create/delete admissions include the requested workspace name in the +key, but omit a workspace UUID guard. Different names have independent request-ID +namespaces; deletion receipts remain replayable after the target disappears. Each sandbox policy revision stores the complete provenance annotation map supplied with that update. The revision payload is the authoritative immutable diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index e5bb5a236a..34153a9ad8 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -646,8 +646,10 @@ resource_mutation!( "CreateWorkspace", workspace::handle_create_workspace, workspace, - async |_req: &CreateWorkspaceRequest, state: &ServerState, principal: &Principal| { - global_scope(state, principal) + async |req: &CreateWorkspaceRequest, state: &ServerState, principal: &Principal| { + let mut scope = global_scope(state, principal)?; + scope.name.clone_from(&req.name); + Ok(scope) } ); deletion_mutation!( @@ -655,8 +657,10 @@ deletion_mutation!( DeleteWorkspaceResponse, "DeleteWorkspace", workspace::handle_delete_workspace, - async |_req: &DeleteWorkspaceRequest, state: &ServerState, principal: &Principal| { - global_scope(state, principal) + async |req: &DeleteWorkspaceRequest, state: &ServerState, principal: &Principal| { + let mut scope = global_scope(state, principal)?; + scope.name.clone_from(&req.name); + Ok(scope) } ); resource_mutation!( diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index e10cb700c4..47b3e90a33 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -183,6 +183,139 @@ async fn sqlite_concurrency_restart_name_reuse_expiry_and_uncertainty() { .await; } +#[tokio::test] +async fn workspace_create_request_id_is_scoped_to_each_target() { + let state = test_server_state().await; + let service = crate::grpc::OpenShellService::new(state.clone()); + let first = create("scope-a"); + let second = CreateWorkspaceRequest { + name: "scope-b".into(), + ..first.clone() + }; + let mut originals = Vec::new(); + for req in [&first, &second] { + let response = service + .create_workspace(authed_request(req.clone())) + .await + .unwrap(); + assert!(response.metadata().get("openshell-replayed").is_none()); + let original = response.into_inner(); + assert_eq!( + original + .workspace + .as_ref() + .unwrap() + .metadata + .as_ref() + .unwrap() + .name, + req.name + ); + assert_eq!( + state + .store + .get_message_by_name::("", &req.name) + .await + .unwrap(), + original.workspace + ); + originals.push(original); + } + assert_ne!( + originals[0].workspace.as_ref().unwrap().object_id(), + originals[1].workspace.as_ref().unwrap().object_id() + ); + for (req, original) in [&first, &second].into_iter().zip(originals) { + let replay = service + .create_workspace(authed_request(req.clone())) + .await + .unwrap(); + assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true"); + assert_eq!(replay.into_inner(), original); + let mut mismatch = req.clone(); + mismatch.labels.insert("changed".into(), "payload".into()); + assert_eq!( + reason( + &service + .create_workspace(authed_request(mismatch)) + .await + .unwrap_err() + ), + "REQUEST_ID_PAYLOAD_MISMATCH" + ); + } + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + for row in rows { + let admission: Admission = serde_json::from_slice(&row.payload).unwrap(); + assert!(admission.workspace_id.is_none()); + } +} + +#[tokio::test] +async fn workspace_delete_request_id_is_scoped_to_each_target() { + let state = test_server_state().await; + let service = crate::grpc::OpenShellService::new(state.clone()); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut requests = Vec::new(); + for name in ["scope-a", "scope-b"] { + service + .create_workspace(authed_request(CreateWorkspaceRequest { + name: name.into(), + ..Default::default() + })) + .await + .unwrap(); + requests.push(DeleteWorkspaceRequest { + name: name.into(), + request_id: request_id.clone(), + ..Default::default() + }); + } + for req in &requests { + let response = service + .delete_workspace(authed_request(req.clone())) + .await + .unwrap(); + assert!(response.metadata().get("openshell-replayed").is_none()); + assert_eq!( + response.get_ref().outcome, + openshell_core::proto::DeletionOutcome::Completed as i32 + ); + assert!( + state + .store + .get_message_by_name::("", &req.name) + .await + .unwrap() + .is_none() + ); + } + for req in requests { + // The target no longer exists; deletion replay must not require its UUID. + let replay = service.delete_workspace(authed_request(req)).await.unwrap(); + assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true"); + assert_eq!( + replay.get_ref().outcome, + openshell_core::proto::DeletionOutcome::Completed as i32 + ); + } + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + for row in rows { + let admission: Admission = serde_json::from_slice(&row.payload).unwrap(); + assert!(admission.workspace_id.is_none()); + } +} + #[tokio::test] #[ignore = "requires a disposable PostgreSQL database in OPENSHELL_REPLAY_TEST_DATABASE_URL"] async fn postgres_concurrency_restart_name_reuse_expiry_and_uncertainty() { @@ -463,7 +596,13 @@ impl Mutation for ControlledCreate { &self.request_id } async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result { - global_scope(state, principal) + // Fault-injected execution must use the real adapter's admission key. + CreateWorkspaceRequest { + name: self.name.clone(), + ..Default::default() + } + .authorize(state, principal) + .await } async fn execute( state: &Arc, diff --git a/docs/reference/api-errors.mdx b/docs/reference/api-errors.mdx index eaa6b99575..3ad2c98a3f 100644 --- a/docs/reference/api-errors.mdx +++ b/docs/reference/api-errors.mdx @@ -111,6 +111,17 @@ subject, RPC method, and requested workspace. Use the same identity, scope, ID, and payload when checking an admitted attempt. Map ordering does not affect the payload fingerprint; changed values or protobuf message presence do. +For `CreateWorkspace` and `DeleteWorkspace`, the requested workspace is the +request's `name`. The same caller can reuse an ID for the same method on different +workspace names. Within one workspace name, a changed payload still returns +`REQUEST_ID_PAYLOAD_MISMATCH`. Workspace deletion replay does not require the +deleted workspace to exist and does not delete a same-name replacement. + +Pre-release gateways that used unscoped workspace admission require reconciliation +before upgrading. Their workspace create/delete receipts do not replay under the +corrected name-scoped namespace. Do not retry those IDs after upgrading without +reconciling the original operations' effects. + The gateway durably admits one owner before executing the mutation. Concurrent duplicates either replay its confirmed success or return `REQUEST_OUTCOME_UNCERTAIN`. Disconnecting or cancelling the client does not stop diff --git a/e2e/python/test_workspace_api.py b/e2e/python/test_workspace_api.py index ab1b8f609a..a60597eb53 100644 --- a/e2e/python/test_workspace_api.py +++ b/e2e/python/test_workspace_api.py @@ -78,6 +78,61 @@ def test_workspace_get_nonexistent_raises_not_found( assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND +def test_workspace_request_ids_are_scoped_to_each_target( + workspace_client: WorkspaceClient, +) -> None: + names = [f"ws-scope-{uuid.uuid4().hex[:8]}" for _ in range(2)] + create_id = str(uuid.uuid4()) + delete_id = str(uuid.uuid4()) + stub = workspace_client._stub + originals = [] + try: + for name in names: + request = openshell_pb2.CreateWorkspaceRequest( + name=name, request_id=create_id + ) + response, call = stub.CreateWorkspace.with_call(request, timeout=20) + assert "openshell-replayed" not in dict(call.initial_metadata()) + assert response.workspace.metadata.name == name + fetched = stub.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=name), timeout=20 + ) + assert fetched.workspace.metadata.id == response.workspace.metadata.id + originals.append(response) + assert originals[0].workspace.metadata.id != originals[1].workspace.metadata.id + + for name, original in zip(names, originals, strict=True): + replay, call = stub.CreateWorkspace.with_call( + openshell_pb2.CreateWorkspaceRequest(name=name, request_id=create_id), + timeout=20, + ) + assert replay == original + assert dict(call.initial_metadata())["openshell-replayed"] == "true" + + for name in names: + request = openshell_pb2.DeleteWorkspaceRequest( + name=name, request_id=delete_id + ) + response, call = stub.DeleteWorkspace.with_call(request, timeout=20) + assert "openshell-replayed" not in dict(call.initial_metadata()) + assert response.outcome == openshell_pb2.DELETION_OUTCOME_COMPLETED + with pytest.raises(grpc.RpcError) as exc_info: + workspace_client.get(name) + assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND + + for name in names: + replay, call = stub.DeleteWorkspace.with_call( + openshell_pb2.DeleteWorkspaceRequest(name=name, request_id=delete_id), + timeout=20, + ) + assert replay.outcome == openshell_pb2.DELETION_OUTCOME_COMPLETED + assert dict(call.initial_metadata())["openshell-replayed"] == "true" + finally: + for name in names: + with contextlib.suppress(Exception): + workspace_client.delete(name, allow_missing=True) + + def test_workspace_request_id_replays_without_deleting_replacement( workspace_client: WorkspaceClient, ) -> None: @@ -109,7 +164,9 @@ def test_workspace_request_id_replays_without_deleting_replacement( ) assert replacement.workspace.metadata.id != original.workspace.metadata.id assert stub.DeleteWorkspace(delete, timeout=20) == removed - fetched = stub.GetWorkspace(openshell_pb2.GetWorkspaceRequest(name=name), timeout=20) + fetched = stub.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=name), timeout=20 + ) assert fetched.workspace.metadata.id == replacement.workspace.metadata.id finally: with contextlib.suppress(Exception):