diff --git a/architecture/gateway.md b/architecture/gateway.md index 5e04f377fa..62eceed3eb 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -62,6 +62,37 @@ transport status, metadata, and unknown details alongside decoded fields. SDK deletion waits recognize missing-resource status through typed error wrappers without suppressing other failures. +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 +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 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: clients present a certificate signed by the local deployment CA, and the @@ -391,7 +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. -It does not add request deduplication or identity preconditions for later retries. +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 | |---|---| @@ -442,6 +474,14 @@ 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 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 record; sandbox metadata receives the same annotations only as a convenience 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 9dbbe763b5..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() { @@ -2685,6 +2686,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 +2874,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)), @@ -3300,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)), @@ -3360,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)), }) @@ -3383,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)), }) @@ -3481,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), @@ -3607,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(), @@ -3827,6 +3835,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 +3990,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 +4028,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 +4063,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(), @@ -5876,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)), @@ -5908,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(), @@ -5949,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)), @@ -5980,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 47f30dfe67..473f34d625 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")), @@ -354,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")), @@ -372,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")), }; @@ -386,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")), }; @@ -509,6 +514,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 +588,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 +803,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 +879,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)), @@ -944,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)), @@ -963,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)), }; @@ -978,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)), }; @@ -1175,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, @@ -1206,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 f66a0a011a..c0f6f926d5 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; +pub mod mutation_replay; pub mod policy; pub mod provider; mod sandbox; @@ -266,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( @@ -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( @@ -338,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 --- @@ -412,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( @@ -433,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( @@ -449,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( @@ -484,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( @@ -505,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( @@ -519,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 --- @@ -584,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( @@ -651,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( @@ -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..34153a9ad8 --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -0,0 +1,708 @@ +// 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. +//! 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, + 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, + #[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 { + 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; + 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: &Response) -> 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> { + 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 { + 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 = 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", + 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 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, + protection, + scope, + ) + .await + }) + .await + .map_err(|_| uncertain())? +} + +async fn execute_owned( + state: &Arc, + 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, + }; + // 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)), + } + } + // 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 + || previous.protection != admission.protection + { + 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 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( + 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) +} + +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); + 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 scope, protected payload, or result is no longer replayable; 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: &Response) -> Result { + resource_success(response.get_ref().$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: &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 }) + } + } + }; +} + +resource_mutation!( + CreateWorkspaceRequest, + CreateWorkspaceResponse, + "CreateWorkspace", + workspace::handle_create_workspace, + workspace, + async |req: &CreateWorkspaceRequest, state: &ServerState, principal: &Principal| { + let mut scope = global_scope(state, principal)?; + scope.name.clone_from(&req.name); + Ok(scope) + } +); +deletion_mutation!( + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + "DeleteWorkspace", + workspace::handle_delete_workspace, + async |req: &DeleteWorkspaceRequest, state: &ServerState, principal: &Principal| { + let mut scope = global_scope(state, principal)?; + scope.name.clone_from(&req.name); + Ok(scope) + } +); +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; + +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 new file mode 100644 index 0000000000..47b3e90a33 --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -0,0 +1,966 @@ +// 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() + } +} + +pub(super) 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() +} + +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; + 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; + ordinary::tests::exercise_protected_backend(url).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] +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() { + 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 { + // 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, + 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: &Response) -> Result { + resource_success(response.get_ref().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 { + protection: None, + 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..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, @@ -169,6 +170,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/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 21e2b3c18c..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(), @@ -6730,6 +6762,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "alpha".to_string(), labels: HashMap::new(), }), @@ -6765,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(), @@ -6778,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(), @@ -6819,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(), @@ -6832,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, @@ -6871,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(), @@ -6920,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, @@ -7015,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, @@ -7087,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(), @@ -7153,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, @@ -7275,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, @@ -7406,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, @@ -7490,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(), @@ -7549,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, @@ -7622,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, @@ -7673,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(), @@ -7735,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, @@ -7794,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(), @@ -7985,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, @@ -8067,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, @@ -8088,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, @@ -8149,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, @@ -8176,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, @@ -8232,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, @@ -8265,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(), @@ -8278,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(), @@ -8318,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(), @@ -8351,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(), @@ -8370,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(), @@ -8845,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", @@ -8868,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(), @@ -8890,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(), @@ -8921,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(), @@ -8934,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(), @@ -8981,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(), @@ -8996,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", @@ -9044,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", @@ -9146,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(), @@ -9161,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", @@ -9199,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", @@ -9466,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(), @@ -9562,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(), @@ -9605,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(), @@ -10149,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(), @@ -10176,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, @@ -10240,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(), @@ -12066,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", @@ -12096,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(), @@ -12117,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", @@ -12146,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(), @@ -12172,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(), @@ -12202,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(), @@ -12243,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(), @@ -12273,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(), @@ -12321,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", @@ -12351,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(), @@ -12395,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(), @@ -12428,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(), @@ -12518,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, @@ -12576,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, @@ -12655,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, @@ -12710,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, @@ -12768,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, @@ -12848,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, @@ -12874,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(), @@ -12943,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, @@ -12997,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, @@ -13177,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, @@ -13254,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, @@ -13438,6 +13539,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), @@ -13459,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 { @@ -13491,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 { @@ -13588,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( @@ -13637,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 { @@ -13709,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(), }), @@ -13725,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() @@ -13758,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(), @@ -13950,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(), @@ -14003,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"), @@ -14080,6 +14191,7 @@ mod tests { handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id, workspace, @@ -14113,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"), @@ -14150,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(), @@ -14195,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(), @@ -14232,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(), @@ -14247,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(), @@ -14280,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(), @@ -14293,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(), @@ -14322,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(), @@ -14335,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(), @@ -14376,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(), @@ -14391,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(), @@ -14425,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(), @@ -14440,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 429a756c7c..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()], @@ -4921,6 +4939,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 +4996,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 +5039,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 +5058,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 +5095,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 +5134,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 +5148,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 +5186,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 +5326,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 +5358,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 +5459,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(), @@ -5455,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()], @@ -5535,6 +5565,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(), @@ -5547,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(), @@ -5586,6 +5618,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(), @@ -5598,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(), @@ -5637,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(), @@ -5661,6 +5696,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(), @@ -5673,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())]), @@ -5701,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(), @@ -5726,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(), @@ -5754,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(), @@ -5797,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, @@ -5844,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, @@ -5899,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, @@ -5946,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, @@ -5973,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, @@ -6185,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, @@ -6237,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, @@ -6300,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, @@ -6352,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, @@ -6433,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, @@ -6497,6 +6547,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), @@ -6813,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(), @@ -6830,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(), }), @@ -6838,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 2d142cd082..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, @@ -704,6 +711,7 @@ mod tests { crate::grpc::workspace::handle_create_workspace( &state, Request::new(CreateWorkspaceRequest { + request_id: String::new(), name: "beta".to_string(), labels: HashMap::new(), }), @@ -735,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, @@ -750,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, @@ -839,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(), @@ -891,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/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/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 e15eed5c4d..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 = - "d323fdd0c989049950ed3bf9cc2cbae9f05e1ae83b13fa2a65a2f5db1ebab95f"; + "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 9f77169ab0..3ad2c98a3f 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 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 @@ -85,6 +88,107 @@ 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 + +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 +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. + +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 +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. 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 +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. 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 Delete, membership-removal, and SSH-revocation RPCs return a typed outcome. 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/e2e/python/test_workspace_api.py b/e2e/python/test_workspace_api.py index 1fb33123eb..a60597eb53 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,98 @@ 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_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: + 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..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 { @@ -1169,6 +1172,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 +1209,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 { @@ -1303,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. @@ -1320,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. @@ -1333,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. @@ -1343,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. @@ -1353,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. @@ -1452,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. @@ -1500,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. @@ -1753,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. @@ -1793,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. @@ -1803,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. @@ -2044,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 { @@ -2057,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 { @@ -2071,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 { @@ -2134,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. @@ -2156,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. @@ -2193,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. @@ -2328,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 { @@ -2911,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 { @@ -2932,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 {} @@ -2954,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 { @@ -2980,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 {} @@ -2994,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 { @@ -3011,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 { @@ -3057,6 +3134,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 +3178,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 +3229,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 +3245,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..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,13 +2754,23 @@ 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"` // 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 +2817,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 +2958,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 +3016,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"` @@ -3476,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() { @@ -3538,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"` @@ -3552,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() { @@ -3614,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"` @@ -3623,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 } @@ -3679,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"` @@ -3686,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() { @@ -3734,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"` @@ -3741,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() { @@ -3789,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"` @@ -4265,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() { @@ -4334,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"` @@ -4537,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() { @@ -4599,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"` @@ -6098,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() { @@ -6146,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"` @@ -6278,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() { @@ -6340,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"` @@ -6347,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() { @@ -6402,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"` @@ -7676,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() { @@ -7759,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"` @@ -7809,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() { @@ -7864,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"` @@ -7915,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() { @@ -7977,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"` @@ -8275,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 } @@ -8324,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"` @@ -8398,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 } @@ -8461,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"` @@ -8681,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 } @@ -8738,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"` @@ -9242,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() { @@ -9346,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: @@ -12786,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() { @@ -12848,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. @@ -12913,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() { @@ -12975,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 @@ -13075,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() { @@ -13137,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. @@ -13221,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() { @@ -13283,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 @@ -13328,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() { @@ -13383,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. @@ -13444,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() { @@ -13492,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. @@ -13716,7 +13975,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 +14026,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 +14293,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 +14345,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 +14468,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 +14526,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 +14586,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 +14643,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"` @@ -15038,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" + @@ -15046,16 +15341,20 @@ 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" + "\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 +15363,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" + @@ -15098,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" + @@ -15147,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" + @@ -15167,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" + @@ -15285,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" + @@ -15296,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" + @@ -15415,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" + @@ -15423,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" + @@ -15464,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" + @@ -15488,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" + @@ -15537,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" + @@ -15549,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" + @@ -15819,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" + @@ -15879,10 +16226,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 +16250,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" +