From 6b2e3378c586977e01e7f67fac0803b8e4bdfcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 16 Jul 2026 12:55:44 +0200 Subject: [PATCH] feat(workspace): proto definitions, persistence, migrations, and server core Part 1/3 of workspace review split (PR 2243 mirror). Proto: Workspace/WorkspaceMember messages, CRUD RPCs, workspace on ObjectMeta, inference Route rename. Server: persistence workspace scoping, migration 006, workspace handler, validation, service routing. --- crates/openshell-core/src/driver_utils.rs | 3 + crates/openshell-core/src/grpc_client.rs | 80 +- crates/openshell-core/src/lib.rs | 4 +- crates/openshell-core/src/metadata.rs | 166 +++- crates/openshell-sandbox/src/lib.rs | 182 ++-- .../postgres/006_add_workspace_column.sql | 15 + .../sqlite/006_add_workspace_column.sql | 15 + .../openshell-server/src/grpc/validation.rs | 73 +- crates/openshell-server/src/grpc/workspace.rs | 800 ++++++++++++++++++ crates/openshell-server/src/inference.rs | 344 ++++++-- crates/openshell-server/src/lib.rs | 50 +- crates/openshell-server/src/multiplex.rs | 4 +- .../openshell-server/src/persistence/mod.rs | 177 +++- .../src/persistence/postgres.rs | 174 +++- .../src/persistence/sqlite.rs | 173 +++- .../openshell-server/src/persistence/tests.rs | 184 ++-- crates/openshell-server/src/policy_store.rs | 12 +- .../openshell-server/src/service_routing.rs | 191 ++++- proto/compute_driver.proto | 3 + proto/datamodel.proto | 19 +- proto/inference.proto | 41 +- proto/openshell.proto | 238 +++++- proto/sandbox.proto | 3 + 23 files changed, 2552 insertions(+), 399 deletions(-) create mode 100644 crates/openshell-server/migrations/postgres/006_add_workspace_column.sql create mode 100644 crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql create mode 100644 crates/openshell-server/src/grpc/workspace.rs diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a1..e5eba760d8 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -27,6 +27,9 @@ pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; /// Container/pod label carrying the sandbox namespace. pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; +/// Container/pod label carrying the sandbox workspace. +pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 373635ae15..99117131ba 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -542,6 +542,36 @@ mod auth_tests { } } +#[cfg(test)] +mod workspace_tests { + use super::*; + + #[test] + fn cached_client_workspace_defaults_to_empty_before_poll() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), ""); + } + + #[test] + fn cached_client_workspace_returns_learned_value() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), "beta"); + } + + #[test] + fn cached_client_workspace_is_set_once() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + let _ = client_ws.set("gamma".to_string()); + assert_eq!( + client_ws.get().cloned().unwrap_or_default(), + "beta", + "workspace should not change after first poll" + ); + } +} + /// Connect to the `OpenShell` server. async fn connect(endpoint: &str) -> Result> { let channel = connect_channel(endpoint).await?; @@ -618,12 +648,19 @@ async fn sync_policy_with_client( client: &mut OpenShellClient, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result<()> { client .update_config(UpdateConfigRequest { name: sandbox.to_string(), policy: Some(policy.clone()), - ..Default::default() + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic() @@ -641,6 +678,7 @@ pub async fn discover_and_sync_policy( sandbox_id: &str, sandbox: &str, discovered_policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { debug!( endpoint = %endpoint, @@ -652,7 +690,7 @@ pub async fn discover_and_sync_policy( let mut client = connect(endpoint).await?; // Sync the discovered policy to the gateway. - sync_policy_with_client(&mut client, sandbox, discovered_policy).await?; + sync_policy_with_client(&mut client, sandbox, discovered_policy, workspace).await?; // Re-fetch from the gateway to get the canonical version/hash. fetch_policy_with_client(&mut client, sandbox_id) @@ -666,10 +704,15 @@ pub async fn discover_and_sync_policy( /// /// Used by the supervisor to push baseline-path-enriched policies so the /// gateway stores the effective policy users see via `openshell sandbox get`. -pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPolicy) -> Result<()> { +pub async fn sync_policy( + endpoint: &str, + sandbox: &str, + policy: &ProtoSandboxPolicy, + workspace: &str, +) -> Result<()> { debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway"); let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await + sync_policy_with_client(&mut client, sandbox, policy, workspace).await } /// Sync an enriched policy and return the authoritative revision snapshot. @@ -678,9 +721,10 @@ pub async fn sync_policy_and_fetch_snapshot( sandbox_id: &str, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await?; + sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; fetch_settings_snapshot_with_client(&mut client, sandbox_id).await } @@ -720,6 +764,7 @@ pub async fn fetch_provider_environment( #[derive(Clone)] pub struct CachedOpenShellClient { client: OpenShellClient, + workspace: Arc>, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -735,6 +780,8 @@ pub struct SettingsPollResult { /// When `policy_source` is `Global`, the version of the global policy revision. pub global_policy_version: u32, pub provider_env_revision: u64, + /// Workspace the sandbox belongs to. + pub workspace: String, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -748,6 +795,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin settings: inner.settings, global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, + workspace: inner.workspace, } } @@ -762,7 +810,10 @@ impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); let client = connect(endpoint).await?; - Ok(Self { client }) + Ok(Self { + client, + workspace: Arc::new(tokio::sync::OnceCell::new()), + }) } /// Get a clone of the underlying tonic client for direct RPC calls. @@ -781,7 +832,20 @@ impl CachedOpenShellClient { .await .into_diagnostic()?; - Ok(settings_poll_result(response.into_inner())) + let result = settings_poll_result(response.into_inner()); + let _ = self.workspace.set(result.workspace.clone()); + Ok(result) + } + + /// Returns the workspace learned from the server, or empty if not yet polled. + pub fn workspace(&self) -> String { + self.workspace.get().cloned().unwrap_or_default() + } + + /// Pre-seed the workspace without polling. The value is ignored if the + /// workspace was already learned from `poll_settings`. + pub fn set_workspace(&self, workspace: String) { + let _ = self.workspace.set(workspace); } /// Submit denial summaries and/or agent-authored proposals for policy analysis. @@ -807,6 +871,7 @@ impl CachedOpenShellClient { proposed_chunks, network_activity_summaries, analysis_mode: analysis_mode.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; @@ -829,6 +894,7 @@ impl CachedOpenShellClient { .get_draft_policy(GetDraftPolicyRequest { name: sandbox_name.to_string(), status_filter: status_filter.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index bb61e1f56f..cd16646b86 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -45,7 +45,9 @@ pub use config::{ MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; -pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; +pub use metadata::{ + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, +}; /// Build version string derived from git metadata. /// diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index af26f73ae0..8794c11d5d 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -7,7 +7,7 @@ use crate::proto::{ InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, + StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember, }; use std::collections::HashMap; @@ -36,6 +36,12 @@ pub trait GetResourceVersion { fn get_resource_version(&self) -> u64; } +/// Provides access to the object's workspace for persistence scoping. +pub trait ObjectWorkspace { + fn object_workspace(&self) -> &str; + fn requires_workspace() -> bool; +} + // Implementations for Sandbox impl ObjectId for Sandbox { fn object_id(&self) -> &str { @@ -69,6 +75,15 @@ impl GetResourceVersion for Sandbox { } } +impl ObjectWorkspace for Sandbox { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + impl Sandbox { pub fn phase(&self) -> i32 { self.status.as_ref().map_or(0, |s| s.phase) @@ -89,6 +104,49 @@ impl Sandbox { } } +// Implementations for Workspace +impl ObjectId for Workspace { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for Workspace { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for Workspace { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for Workspace { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for Workspace { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for Workspace { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for Provider impl ObjectId for Provider { fn object_id(&self) -> &str { @@ -122,6 +180,15 @@ impl GetResourceVersion for Provider { } } +impl ObjectWorkspace for Provider { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for StoredProviderProfile impl ObjectId for StoredProviderProfile { fn object_id(&self) -> &str { @@ -155,6 +222,15 @@ impl GetResourceVersion for StoredProviderProfile { } } +impl ObjectWorkspace for StoredProviderProfile { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for StoredProviderCredentialRefreshState impl ObjectId for StoredProviderCredentialRefreshState { fn object_id(&self) -> &str { @@ -188,6 +264,15 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState { } } +impl ObjectWorkspace for StoredProviderCredentialRefreshState { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for SshSession impl ObjectId for SshSession { fn object_id(&self) -> &str { @@ -221,6 +306,15 @@ impl GetResourceVersion for SshSession { } } +impl ObjectWorkspace for SshSession { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ServiceEndpoint impl ObjectId for ServiceEndpoint { fn object_id(&self) -> &str { @@ -254,6 +348,15 @@ impl GetResourceVersion for ServiceEndpoint { } } +impl ObjectWorkspace for ServiceEndpoint { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for InferenceRoute impl ObjectId for InferenceRoute { fn object_id(&self) -> &str { @@ -287,6 +390,57 @@ impl GetResourceVersion for InferenceRoute { } } +impl ObjectWorkspace for InferenceRoute { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + +// Implementations for WorkspaceMember +impl ObjectId for WorkspaceMember { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for WorkspaceMember { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for WorkspaceMember { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for WorkspaceMember { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for WorkspaceMember { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for WorkspaceMember { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ObjectForTest (test-only proto type) impl ObjectId for ObjectForTest { fn object_id(&self) -> &str { @@ -318,3 +472,13 @@ impl GetResourceVersion for ObjectForTest { 0 } } + +impl ObjectWorkspace for ObjectForTest { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7a1085f1bf..e9adc0c0c2 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -383,79 +383,10 @@ pub async fn run_sandbox( None }; - #[cfg(target_os = "linux")] - let sidecar_control_server = if network_enabled && sidecar_network_enforcement { - if !matches!(policy.network.mode, NetworkMode::Proxy) { - return Err(miette::miette!( - "sidecar network enforcement requires proxy network mode" - )); - } - let socket = sidecar_control_socket().ok_or_else(|| { - miette::miette!( - "{} is required for sidecar topology", - openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET - ) - })?; - let proto = retained_proto.as_ref().ok_or_else(|| { - miette::miette!( - "sidecar topology requires gateway policy data for the process supervisor" - ) - })?; - let ca_paths = networking.as_ref().and_then(|n| n.ca_file_paths.clone()); - Some(sidecar_control::spawn_server( - &socket, - sidecar_control::BootstrapData { - policy_proto: proto.clone(), - provider_env_revision: provider_credentials.snapshot().revision, - provider_child_env: provider_env.clone(), - proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), - proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), - }, - sidecar_expected_peer()?, - )?) - } else { - None - }; - #[cfg(not(target_os = "linux"))] - let sidecar_control_server: Option = None; - - let sidecar_control_publisher = sidecar_control_server - .as_ref() - .map(sidecar_control::ServerHandle::publisher); - - #[cfg(target_os = "linux")] - let mut sidecar_control_task = None; - - #[cfg(target_os = "linux")] - if network_enabled - && sidecar_network_enforcement - && let Some(server) = sidecar_control_server - { - let trusted_ssh_socket_path = ssh_socket_path.clone().ok_or_else(|| { - miette::miette!( - "{} is required for sidecar network topology", - openshell_core::sandbox_env::SSH_SOCKET_PATH - ) - })?; - let (entrypoint_rx, connection_task) = server.into_runtime_parts(); - sidecar_control_task = Some(connection_task); - spawn_sidecar_entrypoint_handler( - entrypoint_rx, - entrypoint_pid.clone(), - opa_engine.clone(), - retained_proto.clone(), - openshell_endpoint.clone(), - sandbox_id.clone(), - std::path::PathBuf::from(trusted_ssh_socket_path), - ); - } - - #[cfg(not(target_os = "linux"))] - if network_enabled && sidecar_network_enforcement { - return Err(miette::miette!( - "sidecar network enforcement is only supported on Linux" - )); - } + // Workspace watch: the policy poll loop learns the workspace from + // GetSandboxConfig and broadcasts it. Flush tasks read the current + // value so proposals target the correct workspace. + let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); // Spawn the denial-aggregator flush task. The aggregator drains denial // events from the proxy + bypass monitor, batches them, and ships @@ -474,15 +405,22 @@ pub async fn run_sandbox( .unwrap_or(10); let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs); + let denial_workspace_rx = workspace_rx.clone(); tokio::spawn(async move { aggregator .run(|summaries| { let endpoint = agg_endpoint.clone(); let sandbox_name = agg_name.clone(); + let workspace = denial_workspace_rx.borrow().clone(); async move { - if let Err(e) = - flush_proposals_to_gateway(&endpoint, &sandbox_name, summaries).await + if let Err(e) = flush_proposals_to_gateway( + &endpoint, + &sandbox_name, + &workspace, + summaries, + ) + .await { warn!(error = %e, "Failed to flush denial summaries to gateway"); } @@ -508,15 +446,18 @@ pub async fn run_sandbox( ); let aggregator = activity_aggregator::ActivityAggregator::new(rx, flush_interval_secs); + let activity_workspace_rx = workspace_rx.clone(); tokio::spawn(async move { aggregator .run(move |summary| { let endpoint = agg_endpoint.clone(); let sandbox_name = agg_name.clone(); + let workspace = activity_workspace_rx.borrow().clone(); async move { if let Err(e) = - flush_activity_to_gateway(&endpoint, &sandbox_name, summary).await + flush_activity_to_gateway(&endpoint, &sandbox_name, &workspace, summary) + .await { warn!(error = %e, "Failed to flush activity summary to gateway"); } @@ -555,7 +496,7 @@ pub async fn run_sandbox( ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, policy_local_ctx: poll_policy_local, - sidecar_control_publisher: sidecar_control_publisher.clone(), + workspace_tx, }; tokio::spawn(async move { @@ -972,12 +913,14 @@ fn process_policy_for_topology( async fn flush_proposals_to_gateway( endpoint: &str, sandbox_name: &str, + workspace: &str, summaries: Vec, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialSummary, L7RequestSample}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summaries: Vec = summaries .into_iter() @@ -1040,12 +983,14 @@ async fn flush_proposals_to_gateway( async fn flush_activity_to_gateway( endpoint: &str, sandbox_name: &str, + workspace: &str, summary: activity_aggregator::FlushableActivitySummary, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialGroupCount, NetworkActivitySummary}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summary = NetworkActivitySummary { network_activity_count: summary.network_activity_count, @@ -1865,12 +1810,14 @@ async fn load_policy( // Sync and re-fetch over a single connection to avoid extra // TLS handshakes. + let ws = snapshot.workspace.clone(); snapshot = grpc_retry("Policy discovery sync", || { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( endpoint, id, sandbox, &discovered, + &ws, ) }) .await?; @@ -1897,6 +1844,7 @@ async fn load_policy( id, sandbox_name, &sync_policy, + &snapshot.workspace, ) .await { @@ -2363,7 +2311,7 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, - sidecar_control_publisher: Option, + workspace_tx: tokio::sync::watch::Sender, } async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { @@ -2396,33 +2344,36 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { // policy revision the supervisor actually loaded. A mismatched result is // reconciled below instead of being recorded as already applied. match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { - InitialPollDisposition::Acknowledge(candidate) => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = candidate.config_revision; - current_policy_hash.clone_from(&candidate.policy_hash); - current_settings = result.settings; - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::initial_loaded(&candidate), - ); - debug!( - config_revision = current_config_revision, - "Settings poll: initial policy matches loaded revision" - ); - } - InitialPollDisposition::Reconcile => pending_result = Some(result), - InitialPollDisposition::TrackOnly => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash.clone(); - current_settings = result.settings; - debug!( - config_revision = current_config_revision, - "Settings poll: tracking gateway config while preserving local policy override" - ); + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { + InitialPollDisposition::Acknowledge(candidate) => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = candidate.config_revision; + current_policy_hash.clone_from(&candidate.policy_hash); + current_settings = result.settings; + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::initial_loaded(&candidate), + ); + debug!( + config_revision = current_config_revision, + "Settings poll: initial policy matches loaded revision" + ); + } + InitialPollDisposition::Reconcile => pending_result = Some(result), + InitialPollDisposition::TrackOnly => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash.clone(); + current_settings = result.settings; + debug!( + config_revision = current_config_revision, + "Settings poll: tracking gateway config while preserving local policy override" + ); + } } - }, + } Err(e) => { warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); } @@ -2435,7 +2386,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } else { tokio::time::sleep(interval).await; match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => result, + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + result + } Err(e) => { debug!(error = %e, "Settings poll: server unreachable, will retry"); continue; @@ -3008,6 +2962,7 @@ filesystem_policy: settings: std::collections::HashMap::new(), global_policy_version: 0, provider_env_revision: 0, + workspace: String::new(), } } @@ -3159,4 +3114,21 @@ filesystem_policy: ); } } + + #[test] + fn settings_snapshot_carries_workspace_for_policy_sync() { + let mut snapshot = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + snapshot.workspace = "beta".to_string(); + + let revision = LoadedPolicyRevision::from_snapshot(&snapshot); + assert_eq!(revision.version, 1); + assert_eq!( + snapshot.workspace, "beta", + "workspace must survive the snapshot so sync_policy_and_fetch_snapshot receives it" + ); + } } diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 8f4242af17..16dc09d11c 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -97,6 +97,46 @@ pub(super) fn reject_newline_chars(value: &str, field_name: &str) -> Result<(), Ok(()) } +// --------------------------------------------------------------------------- +// DNS-1123 label validation +// --------------------------------------------------------------------------- + +/// Validate that a string conforms to DNS-1123 label rules: lowercase +/// alphanumeric and hyphens, no leading/trailing hyphens, no consecutive +/// hyphens, max 63 characters. `field` is used in error messages. +/// +/// Empty names are allowed (the caller decides whether empty is valid). +pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Status> { + if name.is_empty() { + return Ok(()); + } + if name.len() > MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "{field} exceeds maximum length ({} > {MAX_NAME_LEN})", + name.len() + ))); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument(format!( + "{field} must contain only lowercase alphanumeric characters or hyphens", + ))); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument(format!( + "{field} must not start or end with a hyphen", + ))); + } + if name.contains("--") { + return Err(Status::invalid_argument(format!( + "{field} must not contain consecutive hyphens", + ))); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Sandbox spec validation // --------------------------------------------------------------------------- @@ -109,12 +149,7 @@ pub(super) fn validate_sandbox_spec( spec: &openshell_core::proto::SandboxSpec, ) -> Result<(), Status> { // --- request.name --- - if name.len() > MAX_NAME_LEN { - return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_NAME_LEN})", - name.len() - ))); - } + validate_dns1123_label(name, "name")?; // --- spec.providers --- if spec.providers.len() > MAX_PROVIDERS { @@ -871,6 +906,29 @@ mod tests { assert!(err.message().contains("name")); } + #[test] + fn validate_sandbox_spec_rejects_uppercase_name() { + let err = validate_sandbox_spec("MySandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_leading_hyphen_name() { + let err = validate_sandbox_spec("-sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_consecutive_hyphens_name() { + let err = validate_sandbox_spec("my--sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_accepts_single_hyphens_name() { + assert!(validate_sandbox_spec("my-sandbox", &default_spec()).is_ok()); + } + #[test] fn validate_sandbox_spec_accepts_at_limit_providers() { let spec = SandboxSpec { @@ -1180,12 +1238,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: provider_type.to_string(), credentials, config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs new file mode 100644 index 0000000000..b77cb42534 --- /dev/null +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -0,0 +1,800 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace lifecycle handlers. + +#![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> + +use std::sync::Arc; + +use openshell_core::ObjectName; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateWorkspaceRequest, + CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, + GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, + SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, + WorkspaceMember, WorkspaceRole, +}; +use prost::Message; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms, +}; + +use super::{MAX_PAGE_SIZE, clamp_limit}; + +pub const WORKSPACE_OBJECT_TYPE: &str = "workspace"; +pub const DEFAULT_WORKSPACE_NAME: &str = "default"; +const MAX_WORKSPACE_MEMBERS: u32 = 1000; + +impl ObjectType for Workspace { + fn object_type() -> &'static str { + WORKSPACE_OBJECT_TYPE + } +} + +impl ObjectType for WorkspaceMember { + fn object_type() -> &'static str { + "workspace_member" + } +} + +fn validate_workspace_name(name: &str) -> Result<(), Status> { + if name.is_empty() { + return Err(Status::invalid_argument("workspace name is required")); + } + super::validation::validate_dns1123_label(name, "workspace name") +} + +/// Resolve a workspace for provider profile operations. +/// +/// Provider profiles support a platform scope where `""` is a distinct, +/// meaningful value (not an alias for `"default"`). This function preserves +/// `""` as-is for platform-scoped operations. Non-empty workspace values are +/// validated for existence via [`resolve_workspace`]. +pub async fn resolve_profile_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + if workspace.is_empty() { + return Ok(String::new()); + } + resolve_workspace(store, workspace).await +} + +/// Resolve and validate a workspace name from a request field. +/// +/// Empty strings are normalized to `"default"`. The workspace must exist in the +/// store; returns `NOT_FOUND` if it doesn't. +/// +/// TODO(phase2): this only validates existence. Workspace membership enforcement +/// (checking the caller is a member of the resolved workspace) is deferred to +/// Phase 2. +pub async fn resolve_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + let name = if workspace.is_empty() { + DEFAULT_WORKSPACE_NAME.to_string() + } else { + workspace.to_string() + }; + + let exists: Option = store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("workspace lookup failed: {e}")))?; + + if exists.is_none() { + return Err(Status::not_found(format!("workspace '{name}' not found"))); + } + + Ok(name) +} + +pub(super) async fn handle_create_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + validate_workspace_name(&req.name)?; + + let now_ms = current_time_ms(); + let workspace_id = uuid::Uuid::new_v4().to_string(); + + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: workspace_id.clone(), + name: req.name, + created_at_ms: now_ms, + labels: req.labels, + resource_version: 0, + workspace: String::new(), + }), + }; + + super::validation::validate_object_metadata(workspace.metadata.as_ref(), "workspace")?; + + let result = state + .store + .put_if( + Workspace::object_type(), + &workspace_id, + workspace.object_name(), + "", + &workspace.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("workspace already exists") + } else { + Status::internal(format!("persist workspace failed: {e}")) + } + })?; + + let mut workspace = workspace; + if let Some(metadata) = workspace.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(CreateWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_get_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + + let workspace: Workspace = state + .store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("fetch workspace failed: {e}")))? + .ok_or_else(|| Status::not_found("workspace not found"))?; + + Ok(Response::new(GetWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_list_workspaces( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let workspaces: Vec = if req.label_selector.is_empty() { + state + .store + .list_messages("", limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + } else { + state + .store + .list_messages_with_selector("", &req.label_selector, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + }; + + Ok(Response::new(ListWorkspacesResponse { workspaces })) +} + +pub(super) async fn handle_delete_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + if name == DEFAULT_WORKSPACE_NAME { + return Err(Status::failed_precondition( + "the default workspace cannot be deleted", + )); + } + + // TOCTOU: the blocker scan and the subsequent delete are not transactional, + // so a resource could be created between the check and the delete. The + // persistence layer does not currently offer a transaction primitive that + // spans both reads and deletes. Acceptable for now — workspace deletion is + // an admin-only, low-frequency operation. + let mut blocking = Vec::new(); + // Policy revisions and draft chunks are normally deleted when their parent + // sandbox is removed, and refresh state is transient; these checks are a + // safety net against regressions. + for (object_type, label) in [ + (Sandbox::object_type(), "sandbox"), + (Provider::object_type(), "provider"), + (StoredProviderProfile::object_type(), "provider profile"), + (ServiceEndpoint::object_type(), "service"), + (InferenceRoute::object_type(), "inference route"), + (SshSession::object_type(), "ssh session"), + (super::policy::SANDBOX_SETTINGS_OBJECT_TYPE, "sandbox settings"), + (POLICY_OBJECT_TYPE, "sandbox policy"), + (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunk"), + (StoredProviderCredentialRefreshState::object_type(), "credential refresh state"), + ] { + let records = state + .store + .list(object_type, &name, 1, 0) + .await + .map_err(|e| Status::internal(format!("resource check failed: {e}")))?; + if !records.is_empty() { + blocking.push(label); + } + } + if !blocking.is_empty() { + return Err(Status::failed_precondition(format!( + "workspace '{}' still contains resources: {}", + name, + blocking.join(", ") + ))); + } + + // Clean up membership records before deleting the workspace itself. + state + .store + .delete_all_in_workspace(WorkspaceMember::object_type(), &name) + .await + .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?; + + let deleted = state + .store + .delete_by_name(Workspace::object_type(), "", &name) + .await + .map_err(|e| Status::internal(format!("delete workspace failed: {e}")))?; + + Ok(Response::new(DeleteWorkspaceResponse { deleted })) +} + +pub(super) async fn handle_add_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + 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", + )); + } + + let count = state + .store + .count_in_workspace(WorkspaceMember::object_type(), &workspace) + .await + .map_err(|e| Status::internal(format!("count workspace members failed: {e}")))?; + if count >= u64::from(MAX_WORKSPACE_MEMBERS) { + return Err(Status::resource_exhausted(format!( + "workspace has reached the maximum of {MAX_WORKSPACE_MEMBERS} members" + ))); + } + + let member_id = uuid::Uuid::new_v4().to_string(); + let now_ms = current_time_ms(); + + let member = WorkspaceMember { + metadata: Some(ObjectMeta { + id: member_id.clone(), + name: req.principal_subject.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: workspace.clone(), + }), + principal_subject: req.principal_subject, + role: req.role, + }; + + let result = state + .store + .put_if( + WorkspaceMember::object_type(), + &member_id, + member.object_name(), + &workspace, + &member.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("member already exists in this workspace") + } else { + Status::internal(format!("persist workspace member failed: {e}")) + } + })?; + + let mut member = member; + if let Some(metadata) = member.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(AddWorkspaceMemberResponse { + member: Some(member), + })) +} + +pub(super) async fn handle_remove_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + return Err(Status::invalid_argument("principal_subject is required")); + } + + let removed = state + .store + .delete_by_name( + WorkspaceMember::object_type(), + &workspace, + &req.principal_subject, + ) + .await + .map_err(|e| Status::internal(format!("remove workspace member failed: {e}")))?; + + Ok(Response::new(RemoveWorkspaceMemberResponse { removed })) +} + +pub(super) async fn handle_list_workspace_members( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let members: Vec = state + .store + .list_messages(&workspace, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + + Ok(Response::new(ListWorkspaceMembersResponse { members })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + use openshell_core::proto::datamodel::v1::ObjectMeta; + use tonic::{Code, Request}; + + use crate::grpc::test_support::test_server_state; + + #[tokio::test] + async fn delete_workspace_blocked_by_resources() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "ephemeral".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-eph-1".to_string(), + name: "blocker".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "ephemeral".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("sandbox"), + "error should name the blocking resource type: {}", + err.message() + ); + + state + .store + .delete_by_name(Sandbox::object_type(), "ephemeral", "blocker") + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_workspace_blocked_by_ssh_session() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "sessioned".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let session = SshSession { + metadata: Some(ObjectMeta { + id: "ssh-1".to_string(), + name: "session-ssh-1".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "sessioned".to_string(), + }), + sandbox_id: "sbx-1".to_string(), + token: "ssh-1".to_string(), + revoked: false, + expires_at_ms: 0, + }; + state.store.put_message(&session).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "sessioned".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("ssh session"), + "error should name ssh session as blocker: {}", + err.message() + ); + } + + #[tokio::test] + async fn delete_workspace_blocked_by_provider_profiles() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "profiles-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let profile = StoredProviderProfile { + metadata: Some(ObjectMeta { + id: "prof-1".to_string(), + name: "my-profile".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "profiles-ws".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&profile).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("provider profile"), + "error should name provider profile as blocking: {}", + err.message() + ); + + state + .store + .delete_by_name( + StoredProviderProfile::object_type(), + "profiles-ws", + "my-profile", + ) + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_default_workspace_rejected() { + let state = test_server_state().await; + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + } + + #[tokio::test] + async fn add_and_list_workspace_members() { + let state = test_server_state().await; + + let resp = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap() + .into_inner(); + + let member = resp.member.unwrap(); + assert_eq!(member.principal_subject, "alice@example.com"); + assert_eq!(member.role, i32::from(WorkspaceRole::Admin)); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(list.members.len(), 2); + } + + #[tokio::test] + async fn remove_workspace_member() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let resp = handle_remove_workspace_member( + &state, + Request::new(RemoveWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.removed); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert!(list.members.is_empty()); + } + + #[tokio::test] + async fn add_duplicate_member_rejected() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let err = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::AlreadyExists); + } + + #[tokio::test] + async fn delete_workspace_cleans_up_members() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "cleanup-test".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "cleanup-test".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(list.members.len(), 2); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "cleanup-test".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + + // Membership records should have been cleaned up. + let remaining: Vec = state + .store + .list_messages("cleanup-test", 100, 0) + .await + .unwrap(); + assert!( + remaining.is_empty(), + "expected 0 orphaned members, found {}", + remaining.len() + ); + } + + #[test] + fn validate_workspace_name_accepts_single_hyphens() { + validate_workspace_name("my-workspace").unwrap(); + } + + #[test] + fn validate_workspace_name_rejects_uppercase() { + let err = validate_workspace_name("MyWorkspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_rejects_leading_hyphen() { + let err = validate_workspace_name("-workspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_rejects_consecutive_hyphens() { + let err = validate_workspace_name("team--ml").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } +} diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 2b58bd15cd..0bf1c0a891 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -3,16 +3,16 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> -use openshell_core::ObjectId; use openshell_core::inference::{ VERTEX_AI_PROJECT_ID_KEY, VERTEX_AI_PUBLISHER_KEY, VERTEX_AI_REGION_KEY, }; use openshell_core::proto::{ - ClusterInferenceConfig, GetClusterInferenceRequest, GetClusterInferenceResponse, - GetInferenceBundleRequest, GetInferenceBundleResponse, InferenceRoute, Provider, ResolvedRoute, - SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetInferenceRouteRequest, + GetInferenceRouteResponse, InferenceRoute, InferenceRouteConfig, Provider, ResolvedRoute, + Sandbox, SetInferenceRouteRequest, SetInferenceRouteResponse, ValidatedEndpoint, inference_server::Inference, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use openshell_providers::normalize_provider_type; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; @@ -68,26 +68,38 @@ impl Inference for InferenceService { &self, request: Request, ) -> Result, Status> { - authorize_inference_bundle( + let sandbox_id = authorize_inference_bundle( request .extensions() .get::(), )?; - resolve_inference_bundle(self.state.store.as_ref()) + let sandbox: Sandbox = self + .state + .store + .get_message::(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?; + let workspace = sandbox.object_workspace(); + resolve_inference_bundle(self.state.store.as_ref(), workspace) .await .map(Response::new) } #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] - async fn set_cluster_inference( + async fn set_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let verify = !req.no_verify; - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( self.state.store.as_ref(), + &workspace, route_name, &req.provider_name, &req.model_id, @@ -102,7 +114,7 @@ impl Inference for InferenceService { .as_ref() .ok_or_else(|| Status::internal("managed route missing config"))?; - Ok(Response::new(SetClusterInferenceResponse { + Ok(Response::new(SetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.route.version, @@ -110,25 +122,29 @@ impl Inference for InferenceService { validation_performed: !route.validation.is_empty(), validated_endpoints: route.validation, timeout_secs: config.timeout_secs, + workspace, })) } #[rpc_auth(auth = "bearer", scope = "inference:read", role = "user")] - async fn get_cluster_inference( + async fn get_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let route = self .state .store - .get_message_by_name::(route_name) + .get_message_by_name::(&workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))? .ok_or_else(|| { Status::not_found(format!( - "inference route '{route_name}' is not configured; run 'openshell inference set --provider --model '" + "inference route '{route_name}' is not configured in workspace '{workspace}'; run 'openshell inference set --provider --model '" )) })?; @@ -143,18 +159,20 @@ impl Inference for InferenceService { )); } - Ok(Response::new(GetClusterInferenceResponse { + Ok(Response::new(GetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.version, route_name: route_name.to_string(), timeout_secs: config.timeout_secs, + workspace, })) } } -async fn upsert_cluster_inference_route( +async fn upsert_inference_route( store: &Store, + workspace: &str, route_name: &str, provider_name: &str, model_id: &str, @@ -169,11 +187,13 @@ async fn upsert_cluster_inference_route( } let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { - Status::failed_precondition(format!("provider '{provider_name}' not found")) + Status::failed_precondition(format!( + "provider '{provider_name}' not found in workspace '{workspace}'" + )) })?; let resolved = resolve_provider_route(&provider, model_id)?; @@ -183,18 +203,16 @@ async fn upsert_cluster_inference_route( Vec::new() }; - let config = build_cluster_inference_config(&provider, model_id, timeout_secs); + let config = build_inference_route_config(&provider, model_id, timeout_secs); - // Fetch existing route to determine create vs. update path let existing = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; let now_ms = current_time_ms(); let (id, metadata, new_version, condition) = if let Some(existing) = existing { - // Update path: preserve metadata, increment version, use CAS let resource_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); ( existing.object_id().to_string(), @@ -203,7 +221,6 @@ async fn upsert_cluster_inference_route( WriteCondition::MatchResourceVersion(resource_version), ) } else { - // Create path: new metadata, version 1, use MustCreate let new_id = uuid::Uuid::new_v4().to_string(); let new_metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: new_id.clone(), @@ -211,7 +228,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: workspace.to_string(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -222,15 +239,14 @@ async fn upsert_cluster_inference_route( version: new_version, }; - // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) crate::grpc::validate_object_metadata(route.metadata.as_ref(), "inference_route")?; - // Single-attempt CAS write: fails with ABORTED on concurrent modification store .put_if( InferenceRoute::object_type(), &id, route_name, + workspace, &route.encode_to_vec(), None, condition, @@ -241,12 +257,12 @@ async fn upsert_cluster_inference_route( Ok(UpsertedInferenceRoute { route, validation }) } -fn build_cluster_inference_config( +fn build_inference_route_config( provider: &Provider, model_id: &str, timeout_secs: u64, -) -> ClusterInferenceConfig { - ClusterInferenceConfig { +) -> InferenceRouteConfig { + InferenceRouteConfig { provider_name: provider.object_name().to_string(), model_id: model_id.to_string(), timeout_secs, @@ -876,9 +892,9 @@ fn find_provider_config_value(provider: &Provider, preferred_keys: &[&str]) -> O fn authorize_inference_bundle( principal: Option<&crate::auth::principal::Principal>, -) -> Result<(), Status> { +) -> Result { match principal { - Some(crate::auth::principal::Principal::Sandbox(_)) => Ok(()), + Some(crate::auth::principal::Principal::Sandbox(s)) => Ok(s.sandbox_id.clone()), Some(crate::auth::principal::Principal::User(_)) => Err(Status::permission_denied( "GetInferenceBundle requires a sandbox principal", )), @@ -888,13 +904,16 @@ fn authorize_inference_bundle( } } -/// Resolve the inference bundle (all managed routes + revision hash). -async fn resolve_inference_bundle(store: &Store) -> Result { +/// Resolve the inference bundle for a workspace (all managed routes + revision hash). +async fn resolve_inference_bundle( + store: &Store, + workspace: &str, +) -> Result { let mut routes = Vec::new(); - if let Some(r) = resolve_route_by_name(store, CLUSTER_INFERENCE_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, CLUSTER_INFERENCE_ROUTE_NAME).await? { routes.push(r); } - if let Some(r) = resolve_route_by_name(store, SANDBOX_SYSTEM_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, SANDBOX_SYSTEM_ROUTE_NAME).await? { routes.push(r); } @@ -933,10 +952,11 @@ async fn resolve_inference_bundle(store: &Store) -> Result Result, Status> { let route = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; @@ -961,12 +981,12 @@ async fn resolve_route_by_name( } let provider = store - .get_message_by_name::(&config.provider_name) + .get_message_by_name::(workspace, &config.provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { Status::failed_precondition(format!( - "configured provider '{}' was not found", + "configured provider '{}' was not found in workspace '{workspace}'", config.provider_name )) })?; @@ -1031,9 +1051,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: provider_name.to_string(), model_id: model_id.to_string(), timeout_secs: 0, @@ -1050,12 +1070,13 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -1096,8 +1117,9 @@ mod tests { .await .expect("provider should persist"); - let first = upsert_cluster_inference_route( + let first = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -1108,8 +1130,9 @@ mod tests { .expect("first set should succeed"); assert_eq!(first.route.object_name(), CLUSTER_INFERENCE_ROUTE_NAME); - let second = upsert_cluster_inference_route( + let second = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -1148,7 +1171,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1165,14 +1188,16 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("provider should persist"); - let upserted = upsert_cluster_inference_route( + let upserted = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1191,7 +1216,7 @@ mod tests { // auth (empty api_key + provider_type = "aws-bedrock"). Note // the api_key is empty even though the provider has a // credential — auth: None skips api-key lookup entirely. - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1225,7 +1250,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1236,14 +1261,16 @@ mod tests { // Intentionally no BEDROCK_BASE_URL. config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("provider should persist"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-misconfigured", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1274,7 +1301,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1284,6 +1311,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1302,8 +1330,9 @@ mod tests { "tab\there", "newline\nhere", ] { - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", unsafe_model, @@ -1329,7 +1358,7 @@ mod tests { async fn resolve_managed_route_returns_none_when_missing() { let store = test_store().await; - let route = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let route = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolution should not fail"); assert!(route.is_none()); @@ -1348,7 +1377,7 @@ mod tests { let route = make_route(CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "mock/model-a"); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1386,7 +1415,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1428,7 +1457,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1453,7 +1482,7 @@ mod tests { async fn bundle_without_cluster_route_returns_empty_routes() { let store = test_store().await; - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); assert!(resp.routes.is_empty()); @@ -1476,10 +1505,10 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp1 = resolve_inference_bundle(&store) + let resp1 = resolve_inference_bundle(&store, "default") .await .expect("first resolve"); - let resp2 = resolve_inference_bundle(&store) + let resp2 = resolve_inference_bundle(&store, "default") .await .expect("second resolve"); @@ -1500,7 +1529,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1511,6 +1540,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1524,9 +1554,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: "openai-dev".to_string(), model_id: "test/model".to_string(), timeout_secs: 0, @@ -1538,7 +1568,7 @@ mod tests { .await .expect("route should persist"); - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1574,7 +1604,7 @@ mod tests { .await .expect("route should persist"); - let first = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let first = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1587,13 +1617,14 @@ mod tests { .collect(), config: provider.config.clone(), credential_expires_at_ms: provider.credential_expires_at_ms.clone(), + profile_workspace: provider.profile_workspace.clone(), }; store .put_message(&rotated_provider) .await .expect("provider rotation should persist"); - let second = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let second = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1607,8 +1638,9 @@ mod tests { let provider = make_provider("anthropic-dev", "anthropic", "ANTHROPIC_API_KEY", "sk-ant"); store.put_message(&provider).await.expect("persist"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "anthropic-dev", "claude-sonnet-4-20250514", @@ -1625,7 +1657,7 @@ mod tests { } #[tokio::test] - async fn upsert_cluster_inference_route_vertex_ai_anthropic_sets_model_in_path() { + async fn upsert_inference_route_vertex_ai_anthropic_sets_model_in_path() { let store = test_store().await; // Build a Vertex AI provider with the required config and a minted access token. @@ -1636,7 +1668,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1654,14 +1686,16 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("persist provider"); - let result = upsert_cluster_inference_route( + let result = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "vertex-test", "claude-3-5-sonnet@20241022", @@ -1678,7 +1712,7 @@ mod tests { assert_eq!(config.model_id, "claude-3-5-sonnet@20241022"); // Resolve the persisted route and assert Vertex AI Anthropic path contract - let resolved = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let resolved = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolve should not fail") .expect("route should exist after upsert"); @@ -1732,7 +1766,7 @@ mod tests { .await .expect("persist system route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1752,7 +1786,7 @@ mod tests { let system_route = make_route(SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini"); store.put_message(&system_route).await.expect("persist"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1768,8 +1802,9 @@ mod tests { let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); store.put_message(&provider).await.expect("persist"); - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1780,7 +1815,7 @@ mod tests { .expect("upsert should succeed"); let route = store - .get_message_by_name::(SANDBOX_SYSTEM_ROUTE_NAME) + .get_message_by_name::("default", SANDBOX_SYSTEM_ROUTE_NAME) .await .expect("fetch should succeed") .expect("route should exist"); @@ -1825,8 +1860,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1865,8 +1901,9 @@ mod tests { .await .expect("persist provider"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1885,7 +1922,7 @@ mod tests { assert!(err.message().contains("--no-verify")); let persisted = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch route") .is_none(); @@ -1908,8 +1945,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1974,7 +2012,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1984,6 +2022,7 @@ mod tests { .collect(), config, credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -2879,8 +2918,9 @@ mod tests { // Spawn two concurrent upsert calls for the same route (create path) let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2892,8 +2932,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -2950,7 +2991,7 @@ mod tests { // Only one route should exist. let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -2966,8 +3007,9 @@ mod tests { store.put_message(&provider).await.expect("persist"); // Create initial route - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-3.5", @@ -2980,8 +3022,9 @@ mod tests { // Spawn two concurrent updates let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2993,8 +3036,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -3017,7 +3061,7 @@ mod tests { // The route should have one of the new model values and version 2 let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -3037,4 +3081,132 @@ mod tests { route.version ); } + + // ------------------------------------------------------------------------- + // Workspace isolation tests + // ------------------------------------------------------------------------- + + #[tokio::test] + async fn inference_bundle_resolves_workspace_scoped_route() { + let store = test_store().await; + + let alpha_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-alpha".to_string(), + name: "openai-alpha".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "alpha".to_string(), + }), + r#type: "openai".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "sk-alpha-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), + }; + store + .put_message(&alpha_provider) + .await + .expect("persist alpha provider"); + + let beta_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-beta".to_string(), + name: "anthropic-beta".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + r#type: "anthropic".to_string(), + credentials: std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "sk-beta-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), + }; + store + .put_message(&beta_provider) + .await + .expect("persist beta provider"); + + upsert_inference_route( + &store, + "alpha", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-alpha", + "gpt-4", + 0, + false, + ) + .await + .expect("set alpha route"); + + upsert_inference_route( + &store, + "beta", + CLUSTER_INFERENCE_ROUTE_NAME, + "anthropic-beta", + "claude-sonnet-4-20250514", + 0, + false, + ) + .await + .expect("set beta route"); + + let alpha_bundle = resolve_inference_bundle(&store, "alpha") + .await + .expect("alpha bundle should resolve"); + assert_eq!(alpha_bundle.routes.len(), 1); + assert_eq!(alpha_bundle.routes[0].api_key, "sk-alpha-key"); + assert_eq!(alpha_bundle.routes[0].model_id, "gpt-4"); + assert_eq!(alpha_bundle.routes[0].provider_type, "openai"); + + let beta_bundle = resolve_inference_bundle(&store, "beta") + .await + .expect("beta bundle should resolve"); + assert_eq!(beta_bundle.routes.len(), 1); + assert_eq!(beta_bundle.routes[0].api_key, "sk-beta-key"); + assert_eq!(beta_bundle.routes[0].model_id, "claude-sonnet-4-20250514"); + assert_eq!(beta_bundle.routes[0].provider_type, "anthropic"); + } + + #[tokio::test] + async fn inference_bundle_empty_for_workspace_without_route() { + let store = test_store().await; + + let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); + store + .put_message(&provider) + .await + .expect("persist provider"); + + upsert_inference_route( + &store, + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4", + 0, + false, + ) + .await + .expect("set default route"); + + let other_bundle = resolve_inference_bundle(&store, "other-workspace") + .await + .expect("bundle should resolve"); + assert!( + other_bundle.routes.is_empty(), + "workspace with no route should get empty bundle, not inherit from another workspace" + ); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index d40364523c..acacf50afd 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -402,6 +402,8 @@ pub(crate) async fn run_server( // shutdown so the running compute state matches the persisted store. // Runs before watchers spawn so the watch loop sees the post-resume // snapshot on its first poll. + ensure_default_workspace(&store).await?; + if let Err(err) = state.compute.resume_persisted_sandboxes().await { warn!(error = %err, "Failed to resume persisted sandboxes during startup"); } @@ -928,6 +930,50 @@ fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { } } +pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { + use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; + use openshell_core::proto::Workspace; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use prost::Message; + + let id = uuid::Uuid::new_v4().to_string(); + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: id.clone(), + name: DEFAULT_WORKSPACE_NAME.to_string(), + created_at_ms: persistence::current_time_ms(), + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }), + }; + + match store + .put_if( + WORKSPACE_OBJECT_TYPE, + &id, + DEFAULT_WORKSPACE_NAME, + "", + &workspace.encode_to_vec(), + None, + persistence::WriteCondition::MustCreate, + ) + .await + { + Ok(_) => { + info!("Created default workspace"); + Ok(()) + } + Err(persistence::PersistenceError::UniqueViolation { .. }) => { + debug!("Default workspace already exists"); + Ok(()) + } + Err(e) => Err(Error::config(format!( + "failed to ensure default workspace: {e}" + ))), + } +} + #[cfg(test)] mod tests { use super::{ @@ -1062,7 +1108,7 @@ mod tests { fn service_request(addr: SocketAddr, extra_headers: &[(&str, &str)]) -> String { let mut request = format!( - "GET / HTTP/1.1\r\nHost: my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", + "GET / HTTP/1.1\r\nHost: default--my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", addr.port() ); for (name, value) in extra_headers { @@ -1193,7 +1239,7 @@ mod tests { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let origin = format!( - "http://my-sandbox--web.dev.openshell.localhost:{}", + "http://default--my-sandbox--web.dev.openshell.localhost:{}", addr.port() ); let response = send_plain_http( diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 5c1f9dad4f..83e27d27cb 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -2149,8 +2149,8 @@ mod tests { "/openshell.v1.OpenShell/DeleteSandbox", "/openshell.v1.OpenShell/CreateProvider", "/openshell.v1.OpenShell/ApproveDraftChunk", - "/openshell.inference.v1.Inference/GetClusterInference", - "/openshell.inference.v1.Inference/SetClusterInference", + "/openshell.inference.v1.Inference/GetInferenceRoute", + "/openshell.inference.v1.Inference/SetInferenceRoute", ] { let mock = Arc::new(MockAuthenticator::returning(Ok(Some(sandbox_principal())))); let chain = AuthenticatorChain::new(vec![mock]); diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 8b6172c1c6..d5e4216f70 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -81,6 +81,7 @@ pub struct ObjectRecord { pub object_type: String, pub id: String, pub name: String, + pub workspace: String, pub payload: Vec, pub created_at_ms: i64, pub updated_at_ms: i64, @@ -125,7 +126,7 @@ pub trait ObjectType { // Import object metadata accessor traits from openshell-core // (implementations for all proto types are in openshell-core::metadata) pub use openshell_core::{ - GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion, + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, }; /// Generate a random 6-character lowercase alphabetic name. @@ -139,6 +140,11 @@ pub fn generate_name() -> String { /// Decode a single [`ObjectRecord`] into a protobuf message, hydrating /// `resource_version` from the authoritative DB row. /// +/// Only `resource_version` is hydrated here; `workspace` is NOT backfilled from +/// the DB column because the workspace field is authoritative in the protobuf +/// payload at creation time. This is a breaking upgrade — pre-workspace records +/// will carry an empty workspace until they are re-created. +/// /// Extracted to avoid repeating the identical decode-and-hydrate block across /// `get_message`, `get_message_by_name`, `list_messages`, and /// `list_messages_with_selector`. @@ -221,6 +227,7 @@ impl Store { /// * `object_type` - Type discriminator for the object /// * `id` - Stable object identifier /// * `name` - Human-readable object name + /// * `workspace` - Workspace scope for multi-tenant isolation /// * `payload` - Serialized object data /// * `labels` - Optional JSON-serialized labels /// * `condition` - Write precondition (`MustCreate`, `MatchResourceVersion`, or `Unconditional`) @@ -229,16 +236,18 @@ impl Store { /// * `Ok(WriteResult)` - Write succeeded with new `resource_version` and timestamps /// * `Err(Conflict)` - Resource version mismatch (for `MatchResourceVersion`) /// * `Err(UniqueViolation)` - Object already exists (for `MustCreate`) or name conflict + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, ) -> PersistenceResult { - store_dispatch!(self.put_if(object_type, id, name, payload, labels, condition)) + store_dispatch!(self.put_if(object_type, id, name, workspace, payload, labels, condition)) } /// Delete an object by id with compare-and-swap support. @@ -262,16 +271,18 @@ impl Store { } /// Insert or update a generic named object with an application-owned scope. + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put_scoped(object_type, id, name, scope, payload, labels)) + store_dispatch!(self.put_scoped(object_type, id, name, workspace, scope, payload, labels)) } /// Fetch an object by id. @@ -283,13 +294,14 @@ impl Store { store_dispatch!(self.get(object_type, id)) } - /// Fetch an object by name within an object type. + /// Fetch an object by name within an object type and workspace. pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { - store_dispatch!(self.get_by_name(object_type, name)) + store_dispatch!(self.get_by_name(object_type, workspace, name)) } /// Delete an object by id. @@ -297,22 +309,59 @@ impl Store { store_dispatch!(self.delete(object_type, id)) } - /// Delete an object by name within an object type. - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - store_dispatch!(self.delete_by_name(object_type, name)) + /// Count objects of a given type within a workspace. + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.count_in_workspace(object_type, workspace)) + } + + /// Delete all objects of a given type within a workspace. + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_all_in_workspace(object_type, workspace)) + } + + /// Delete an object by name within an object type and workspace. + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_by_name(object_type, workspace, name)) } - /// List objects by type. + /// List objects by type and workspace. pub async fn list( + &self, + object_type: &str, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list(object_type, workspace, limit, offset)) + } + + /// List objects by type across all workspaces. + pub async fn list_by_type( &self, object_type: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list(object_type, limit, offset)) + store_dispatch!(self.list_by_type(object_type, limit, offset)) } /// List objects by type and application-owned scope. + /// + /// Workspace filtering is intentionally omitted: scope values are sandbox + /// UUIDs which are globally unique. Revisit if non-UUID scopes are introduced. pub async fn list_by_scope( &self, object_type: &str, @@ -323,16 +372,39 @@ impl Store { store_dispatch!(self.list_by_scope(object_type, scope, limit, offset)) } - /// List objects by type with label selector filtering. + /// List objects by type and workspace with label selector filtering. /// Label selector format: "key1=value1,key2=value2" (comma-separated equality matches). pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list_with_selector( + object_type, + workspace, + label_selector, + limit, + offset + )) + } + + /// List objects by type across all workspaces with label selector filtering. + pub async fn list_all_with_selector( &self, object_type: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list_with_selector(object_type, label_selector, limit, offset)) + store_dispatch!(self.list_all_with_selector( + object_type, + label_selector, + limit, + offset + )) } // ----------------------------------------------------------------------- @@ -341,12 +413,18 @@ impl Store { /// Insert or update a protobuf message under an application-owned scope. pub async fn put_scoped_message< - T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels, + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, >( &self, message: &T, scope: &str, ) -> PersistenceResult<()> { + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -360,6 +438,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), scope, &message.encode_to_vec(), labels_json.as_deref(), @@ -378,25 +457,59 @@ impl Store { .transpose() } - /// Fetch and decode a protobuf message by name. + /// Fetch and decode a protobuf message by workspace and name. pub async fn get_message_by_name( &self, + workspace: &str, name: &str, ) -> PersistenceResult> { - self.get_by_name(T::object_type(), name) + self.get_by_name(T::object_type(), workspace, name) .await? .map(decode_record) .transpose() } - /// List and decode protobuf messages, hydrating `resource_version` from - /// the authoritative DB row (mirrors `get_message`). + /// List and decode protobuf messages by workspace, hydrating + /// `resource_version` from the authoritative DB row (mirrors `get_message`). pub async fn list_messages( &self, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list(T::object_type(), limit, offset) + self.list(T::object_type(), workspace, limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + + /// List and decode protobuf messages across all workspaces, hydrating + /// `resource_version` from the authoritative DB row. + pub async fn list_all_messages( + &self, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list_by_type(T::object_type(), limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + + /// List and decode protobuf messages across all workspaces with label + /// selector filtering, hydrating `resource_version` from the authoritative + /// DB row. + pub async fn list_all_messages_with_selector< + T: Message + Default + ObjectType + SetResourceVersion, + >( + &self, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list_all_with_selector(T::object_type(), label_selector, limit, offset) .await? .into_iter() .map(decode_record) @@ -409,11 +522,12 @@ impl Store { T: Message + Default + ObjectType + SetResourceVersion, >( &self, + workspace: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list_with_selector(T::object_type(), label_selector, limit, offset) + self.list_with_selector(T::object_type(), workspace, label_selector, limit, offset) .await? .into_iter() .map(decode_record) @@ -450,6 +564,7 @@ impl Store { + ObjectId + ObjectName + ObjectLabels + + ObjectWorkspace + SetResourceVersion + GetResourceVersion + Clone, @@ -491,12 +606,20 @@ impl Store { })?) }; + if T::requires_workspace() && updated.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } + // Single-attempt CAS write - fails with Conflict on version mismatch let result = self .put_if( T::object_type(), updated.object_id(), updated.object_name(), + updated.object_workspace(), &updated.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -531,7 +654,7 @@ fn infer_sqlite_unique_constraint(message: &str) -> Option { Some("objects_version_uq".to_string()) } else if message.contains("objects.object_type, objects.scope, objects.dedup_key") { Some("objects_dedup_uq".to_string()) - } else if message.contains("objects.object_type, objects.name") { + } else if message.contains("objects.object_type, objects.workspace, objects.name") { Some("objects_name_uq".to_string()) } else if message.contains("objects.id") { Some("objects_pkey".to_string()) @@ -596,16 +719,25 @@ impl Store { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put(object_type, id, name, payload, labels)) + store_dispatch!(self.put(object_type, id, name, workspace, payload, labels)) } - pub async fn put_message( + pub async fn put_message< + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, + >( &self, message: &T, ) -> PersistenceResult<()> { + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -618,6 +750,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), &message.encode_to_vec(), labels_json.as_deref(), ) diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index e3f9e5a7b3..b33ed669ad 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -63,6 +63,7 @@ impl PostgresStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -74,9 +75,9 @@ impl PostgresStore { sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb)) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb)) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels @@ -85,6 +86,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -94,11 +96,13 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -114,14 +118,15 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET // Insert only - fail if object exists let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) RETURNING resource_version, created_at_ms, updated_at_ms ", ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -181,9 +186,9 @@ RETURNING resource_version, created_at_ms, updated_at_ms // Unconditional upsert by name let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels, @@ -194,6 +199,7 @@ RETURNING resource_version, created_at_ms, updated_at_ms .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -245,11 +251,13 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -262,9 +270,9 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 sqlx::query( r" -INSERT INTO objects (object_type, id, name, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, COALESCE($8, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET scope = EXCLUDED.scope, payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, @@ -275,6 +283,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -292,7 +301,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND id = $2 ", @@ -309,16 +318,18 @@ WHERE object_type = $1 AND id = $2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects -WHERE object_type = $1 AND name = $2 +WHERE object_type = $1 AND workspace = $2 AND name = $3 ", ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -337,25 +348,92 @@ WHERE object_type = $1 AND name = $2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - let result = sqlx::query("DELETE FROM objects WHERE object_type = $1 AND name = $2") - .bind(object_type) - .bind(name) - .execute(&self.pool) - .await - .map_err(|e| map_db_error(&e))?; + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(u64::try_from(row.0).unwrap_or(0)) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2 AND name = $3", + ) + .bind(object_type) + .bind(workspace) + .bind(name) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; Ok(result.rows_affected() > 0) } pub async fn list( &self, object_type: &str, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND workspace = $2 +ORDER BY created_at_ms ASC, name ASC +LIMIT $3 OFFSET $4 +", + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( + &self, + object_type: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms ASC, name ASC @@ -381,7 +459,7 @@ LIMIT $2 OFFSET $3 ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels FROM objects WHERE object_type = $1 AND scope = $2 ORDER BY created_at_ms ASC, name ASC @@ -400,6 +478,41 @@ LIMIT $3 OFFSET $4 } pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let labels_jsonb = serde_json::to_value(&required_labels) + .map_err(|e| PersistenceError::Encode(format!("failed to serialize labels: {e}")))?; + + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND workspace = $2 AND labels @> $3 +ORDER BY created_at_ms ASC, name ASC +LIMIT $4 OFFSET $5 +", + ) + .bind(object_type) + .bind(workspace) + .bind(&labels_jsonb) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_all_with_selector( &self, object_type: &str, label_selector: &str, @@ -414,7 +527,7 @@ LIMIT $3 OFFSET $4 let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND labels @> $2 ORDER BY created_at_ms ASC, name ASC @@ -436,6 +549,7 @@ LIMIT $3 OFFSET $4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -458,9 +572,9 @@ LIMIT $3 OFFSET $4 sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $7) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8) ", ) .bind(POLICY_OBJECT_TYPE) @@ -470,6 +584,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -733,6 +848,7 @@ WHERE object_type = $1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING id gives the row's effective id whether INSERT inserted @@ -741,9 +857,9 @@ WHERE object_type = $1 let row = sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms + object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (object_type, scope, dedup_key) WHERE dedup_key IS NOT NULL DO UPDATE SET hit_count = objects.hit_count + EXCLUDED.hit_count, updated_at_ms = EXCLUDED.updated_at_ms @@ -759,6 +875,7 @@ RETURNING id .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -988,6 +1105,7 @@ fn row_to_object_record(row: sqlx::postgres::PgRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 0b1f50df6a..b594d07c8a 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -89,6 +89,7 @@ impl SqliteStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -96,9 +97,9 @@ impl SqliteStore { sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels" @@ -107,6 +108,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -116,11 +118,13 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -132,13 +136,14 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET // Insert only - fail if object exists sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) "#, ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -199,9 +204,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 // Unconditional upsert by name sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels", @@ -211,6 +216,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -219,9 +225,12 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .map_err(|e| map_db_error(&e))?; // Fetch the result to get the resource_version - let record = self.get_by_name(object_type, name).await?.ok_or_else(|| { - PersistenceError::Database("object disappeared after upsert".to_string()) - })?; + let record = self + .get_by_name(object_type, workspace, name) + .await? + .ok_or_else(|| { + PersistenceError::Database("object disappeared after upsert".to_string()) + })?; Ok(WriteResult { resource_version: record.resource_version, @@ -265,11 +274,13 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -278,9 +289,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "scope" = excluded."scope", "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", @@ -291,6 +302,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -308,7 +320,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 AND "id" = ?2 "#, @@ -325,16 +337,18 @@ WHERE "object_type" = ?1 AND "id" = ?2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 "#, ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -358,14 +372,58 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + r#" +SELECT COUNT(*) FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(u64::try_from(row.0).unwrap_or(0)) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + r#" +DELETE FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { let result = sqlx::query( r#" DELETE FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 "#, ) .bind(object_type) + .bind(workspace) .bind(name) .execute(&self.pool) .await @@ -376,12 +434,39 @@ WHERE "object_type" = ?1 AND "name" = ?2 pub async fn list( &self, object_type: &str, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +ORDER BY "created_at_ms" ASC, "name" ASC +LIMIT ?3 OFFSET ?4 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( + &self, + object_type: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 ORDER BY "created_at_ms" ASC, "name" ASC @@ -407,7 +492,7 @@ LIMIT ?2 OFFSET ?3 ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels" FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?2 ORDER BY "created_at_ms" ASC, "name" ASC @@ -427,6 +512,7 @@ LIMIT ?3 OFFSET ?4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -434,7 +520,37 @@ LIMIT ?3 OFFSET ?4 use super::parse_label_selector; let required_labels = parse_label_selector(label_selector)?; - let all_records = self.list(object_type, u32::MAX, 0).await?; + let all_records = self.list(object_type, workspace, u32::MAX, 0).await?; + + let filtered: Vec = all_records + .into_iter() + .filter(|record| { + let labels_json = record.labels.as_deref().unwrap_or("{}"); + let labels: std::collections::HashMap = + serde_json::from_str(labels_json).unwrap_or_default(); + + required_labels + .iter() + .all(|(key, value)| labels.get(key).is_some_and(|v| v == value)) + }) + .skip(offset as usize) + .take(limit as usize) + .collect(); + + Ok(filtered) + } + + pub async fn list_all_with_selector( + &self, + object_type: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let all_records = self.list_by_type(object_type, u32::MAX, 0).await?; let filtered: Vec = all_records .into_iter() @@ -457,6 +573,7 @@ LIMIT ?3 OFFSET ?4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -479,9 +596,9 @@ LIMIT ?3 OFFSET ?4 sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8) "#, ) .bind(POLICY_OBJECT_TYPE) @@ -491,6 +608,7 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -757,6 +875,7 @@ WHERE "object_type" = ?1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING "id" gives us the row's effective id regardless of @@ -766,9 +885,9 @@ WHERE "object_type" = ?1 let row = sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT ("object_type", "scope", "dedup_key") WHERE "dedup_key" IS NOT NULL DO UPDATE SET "hit_count" = "objects"."hit_count" + excluded."hit_count", "updated_at_ms" = excluded."updated_at_ms" @@ -784,6 +903,7 @@ RETURNING "id" .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -1044,6 +1164,7 @@ fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index c92f63827a..57fcf873c7 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -13,7 +13,7 @@ async fn sqlite_put_get_round_trip() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -28,7 +28,7 @@ async fn sqlite_put_get_round_trip() { async fn sqlite_connect_runs_embedded_migrations() { let store = test_store().await; - let records = store.list("sandbox", 10, 0).await.unwrap(); + let records = store.list("sandbox", "default", 10, 0).await.unwrap(); assert!(records.is_empty()); } @@ -214,14 +214,14 @@ async fn sqlite_updates_timestamp() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); let first = store.get("sandbox", "abc").await.unwrap().unwrap(); store - .put("sandbox", "abc", "my-sandbox", b"payload2", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload2", None) .await .unwrap(); @@ -239,12 +239,12 @@ async fn sqlite_list_paging() { let name = format!("name-{idx}"); let payload = format!("payload-{idx}"); store - .put("sandbox", &id, &name, payload.as_bytes(), None) + .put("sandbox", &id, &name, "default", payload.as_bytes(), None) .await .unwrap(); } - let records = store.list("sandbox", 2, 1).await.unwrap(); + let records = store.list("sandbox", "default", 2, 1).await.unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].name, "name-1"); assert_eq!(records[1].name, "name-2"); @@ -255,7 +255,7 @@ async fn sqlite_delete_behavior() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -294,12 +294,12 @@ async fn sqlite_get_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); let record = store - .get_by_name("sandbox", "my-sandbox") + .get_by_name("sandbox", "default", "my-sandbox") .await .unwrap() .unwrap(); @@ -307,7 +307,10 @@ async fn sqlite_get_by_name() { assert_eq!(record.name, "my-sandbox"); assert_eq!(record.payload, b"payload"); - let missing = store.get_by_name("sandbox", "no-such-name").await.unwrap(); + let missing = store + .get_by_name("sandbox", "default", "no-such-name") + .await + .unwrap(); assert!(missing.is_none()); } @@ -324,7 +327,7 @@ async fn sqlite_get_message_by_name() { store.put_message(&object).await.unwrap(); let loaded = store - .get_message_by_name::("my-test") + .get_message_by_name::("", "my-test") .await .unwrap() .unwrap(); @@ -333,7 +336,7 @@ async fn sqlite_get_message_by_name() { assert_eq!(loaded.count, 7); let missing = store - .get_message_by_name::("no-such-name") + .get_message_by_name::("", "no-such-name") .await .unwrap(); assert!(missing.is_none()); @@ -344,14 +347,20 @@ async fn sqlite_delete_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); - let deleted = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(deleted); - let deleted_again = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted_again = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(!deleted_again); let gone = store.get("sandbox", "id-1").await.unwrap(); @@ -363,18 +372,32 @@ async fn sqlite_name_unique_per_object_type() { let store = test_store().await; store - .put("sandbox", "id-1", "shared-name", b"payload1", None) + .put( + "sandbox", + "id-1", + "shared-name", + "default", + b"payload1", + None, + ) .await .unwrap(); // Same name, same object_type, different id -> upsert on name. store - .put("sandbox", "id-2", "shared-name", b"payload2", None) + .put( + "sandbox", + "id-2", + "shared-name", + "default", + b"payload2", + None, + ) .await .unwrap(); let record = store - .get_by_name("sandbox", "shared-name") + .get_by_name("sandbox", "default", "shared-name") .await .unwrap() .unwrap(); @@ -383,7 +406,14 @@ async fn sqlite_name_unique_per_object_type() { // Same name, different object_type -> should succeed. store - .put("secret", "id-3", "shared-name", b"payload3", None) + .put( + "secret", + "id-3", + "shared-name", + "default", + b"payload3", + None, + ) .await .unwrap(); } @@ -393,14 +423,14 @@ async fn sqlite_id_globally_unique() { let store = test_store().await; store - .put("sandbox", "same-id", "name-a", b"payload1", None) + .put("sandbox", "same-id", "name-a", "default", b"payload1", None) .await .unwrap(); // Same id, different object_type -> should fail because ids remain global // primary keys even when writes upsert on name. let result = store - .put("secret", "same-id", "name-b", b"payload2", None) + .put("secret", "same-id", "name-b", "default", b"payload2", None) .await; assert!(result.is_err()); @@ -446,6 +476,7 @@ async fn labels_round_trip() { "sandbox", "id-1", "labeled-sandbox", + "default", b"payload", Some(labels), ) @@ -461,11 +492,25 @@ async fn label_selector_single_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"dev"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"dev"}"#), + ) .await .unwrap(); store @@ -473,6 +518,7 @@ async fn label_selector_single_match() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -480,7 +526,7 @@ async fn label_selector_single_match() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -499,6 +545,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-1", "s1", + "default", b"p1", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -509,6 +556,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-2", "s2", + "default", b"p2", Some(r#"{"env":"prod","team":"data"}"#), ) @@ -519,6 +567,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"dev","team":"platform"}"#), ) @@ -526,7 +575,7 @@ async fn label_selector_multiple_labels() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod,team=platform", 10, 0) + .list_with_selector("sandbox", "default", "env=prod,team=platform", 10, 0) .await .unwrap(); @@ -539,12 +588,19 @@ async fn label_selector_no_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=staging", 10, 0) + .list_with_selector("sandbox", "default", "env=staging", 10, 0) .await .unwrap(); @@ -559,25 +615,32 @@ async fn label_selector_respects_paging() { let id = format!("id-{idx}"); let name = format!("name-{idx}"); store - .put("sandbox", &id, &name, b"payload", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + &id, + &name, + "default", + b"payload", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); } let page1 = store - .list_with_selector("sandbox", "env=prod", 2, 0) + .list_with_selector("sandbox", "default", "env=prod", 2, 0) .await .unwrap(); assert_eq!(page1.len(), 2); let page2 = store - .list_with_selector("sandbox", "env=prod", 2, 2) + .list_with_selector("sandbox", "default", "env=prod", 2, 2) .await .unwrap(); assert_eq!(page2.len(), 2); let page3 = store - .list_with_selector("sandbox", "env=prod", 2, 4) + .list_with_selector("sandbox", "default", "env=prod", 2, 4) .await .unwrap(); assert_eq!(page3.len(), 1); @@ -588,16 +651,23 @@ async fn empty_labels_not_matched_by_selector() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", None) + .put("sandbox", "id-1", "s1", "default", b"p1", None) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -743,7 +813,7 @@ async fn policy_put_and_get_latest() { let policy_v1 = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "hash1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "hash1") .await .unwrap(); @@ -760,7 +830,7 @@ async fn policy_put_and_get_latest() { } .encode_to_vec(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "hash2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "hash2") .await .unwrap(); @@ -780,11 +850,11 @@ async fn policy_get_by_version() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "h2") .await .unwrap(); @@ -814,7 +884,7 @@ async fn policy_update_status_and_get_loaded() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -845,7 +915,7 @@ async fn policy_status_failed_with_error() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -869,15 +939,15 @@ async fn policy_supersede_older() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -922,15 +992,15 @@ async fn policy_list_ordered_by_version_desc() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -958,11 +1028,11 @@ async fn policy_isolation_between_sandboxes() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_s1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_s1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-2", 1, &policy_s2, "h2") + .put_policy_revision("p2", "sandbox-2", "default", 1, &policy_s2, "h2") .await .unwrap(); @@ -1060,6 +1130,7 @@ async fn cas_put_if_must_create_succeeds() { "sandbox", "id-1", "new-sandbox", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1086,6 +1157,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-1", + "default", b"payload1", None, WriteCondition::MustCreate, @@ -1099,6 +1171,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-2", + "default", b"payload2", None, WriteCondition::MustCreate, @@ -1123,6 +1196,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1136,6 +1210,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1162,6 +1237,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1175,6 +1251,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(99), @@ -1205,6 +1282,7 @@ async fn cas_delete_if_succeeds_with_correct_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1230,6 +1308,7 @@ async fn cas_delete_if_fails_with_wrong_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1262,6 +1341,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1276,6 +1356,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1290,6 +1371,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v3", None, WriteCondition::MatchResourceVersion(2), @@ -1315,6 +1397,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"initial", None, WriteCondition::MustCreate, @@ -1332,6 +1415,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", format!("update-{i}").as_bytes(), None, WriteCondition::MatchResourceVersion(1), @@ -1373,7 +1457,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), spec: None, status: None, @@ -1413,7 +1497,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 7e3b7856a4..04f7466716 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -99,6 +99,7 @@ pub trait PolicyStoreExt { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -165,6 +166,7 @@ pub trait PolicyStoreExt { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult; async fn get_draft_chunk(&self, id: &str) -> PersistenceResult>; @@ -210,6 +212,7 @@ impl PolicyStoreExt for Store { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -217,12 +220,12 @@ impl PolicyStoreExt for Store { match self { Self::Postgres(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } Self::Sqlite(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } } @@ -323,10 +326,11 @@ impl PolicyStoreExt for Store { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { match self { - Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key).await, - Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key).await, + Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, + Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, } } diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 1dabb77442..2aeb272738 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -48,10 +48,11 @@ pub fn endpoint_key(sandbox: &str, service: &str) -> String { pub fn endpoint_url( config: &openshell_core::Config, + workspace: &str, sandbox: &str, service: &str, ) -> Option { - let host = endpoint_host(&config.service_routing, sandbox, service)?; + let host = endpoint_host(&config.service_routing, workspace, sandbox, service)?; let scheme = endpoint_scheme(config); let port = config.bind_address.port(); let include_port = !matches!((scheme, port), ("https", 443) | ("http", 80)); @@ -73,34 +74,55 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { } } -fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { +// The DNS label before the base domain can exceed the 63-character limit +// (RFC 1035) when workspace + sandbox + service names are long. This is a +// pre-existing gap — sandbox names alone could already exceed the limit +// before workspace scoping was added. A future validation pass should +// enforce the combined label length at creation time. +fn endpoint_host( + config: &ServiceRoutingConfig, + workspace: &str, + sandbox: &str, + service: &str, +) -> Option { let base_domain = config.base_domains.first()?; Some(if service.is_empty() { - format!("{sandbox}.{base_domain}") + format!("{workspace}--{sandbox}.{base_domain}") } else { - format!("{sandbox}--{service}.{base_domain}") + format!("{workspace}--{sandbox}--{service}.{base_domain}") }) } -pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { +// The `--` delimiter is unambiguous because both workspace and sandbox name +// validation reject consecutive hyphens (see `validate_workspace_name` and +// `validate_sandbox_spec`). +pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String, String)> { let host = host.split_once(':').map_or(host, |(name, _)| name); for base_domain in &config.base_domains { let expected_suffix = format!(".{base_domain}"); let Some(encoded) = host.strip_suffix(&expected_suffix) else { continue; }; - let (sandbox, service) = if let Some((sandbox, service)) = encoded.split_once("--") { + let (workspace, rest) = encoded.split_once("--")?; + if workspace.is_empty() || workspace.contains("--") { + return None; + } + let (sandbox, service) = if let Some((sandbox, service)) = rest.split_once("--") { if service.is_empty() || service.contains("--") { return None; } (sandbox, service) } else { - (encoded, "") + (rest, "") }; if sandbox.is_empty() || sandbox.contains("--") { return None; } - return Some((sandbox.to_string(), service.to_string())); + return Some(( + workspace.to_string(), + sandbox.to_string(), + service.to_string(), + )); } None } @@ -116,11 +138,13 @@ pub async fn proxy_sandbox_service_request( let Some(host) = request_host(&req) else { return StatusCode::NOT_FOUND.into_response(); }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return StatusCode::NOT_FOUND.into_response(); }; - match proxy_to_endpoint(state, req, sandbox_name, service_name).await { + match proxy_to_endpoint(state, req, &workspace, sandbox_name, service_name).await { Ok(response) => response.into_response(), Err(err) => err.into_response(), } @@ -209,10 +233,12 @@ pub fn service_error_response(status: StatusCode, message: &'static str) -> Axum async fn proxy_to_endpoint( state: Arc, mut req: Request, + workspace: &str, sandbox_name: String, service_name: String, ) -> Result, ServiceRouteError> { - let endpoint = match load_endpoint(&state.store, &sandbox_name, &service_name).await { + let endpoint = match load_endpoint(&state.store, workspace, &sandbox_name, &service_name).await + { Ok(endpoint) => endpoint, Err(err) => { emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); @@ -409,12 +435,13 @@ async fn proxy_to_endpoint( async fn load_endpoint( store: &Store, + workspace: &str, sandbox_name: &str, service_name: &str, ) -> Result { let key = endpoint_key(sandbox_name, service_name); store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|err| { warn!(error = %err, endpoint = %key, "sandbox service routing: failed to load service endpoint"); @@ -572,7 +599,9 @@ pub fn emit_cross_origin_service_http_rejection(state: &ServerState, req: &Reque let Some(host) = request_host(req) else { return; }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((_workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return; }; let err = ServiceRouteError::new( @@ -804,7 +833,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: "default".to_string(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), @@ -840,8 +869,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("http://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("http://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -852,8 +881,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "").as_deref(), - Some("http://my-sandbox.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "").as_deref(), + Some("http://default--my-sandbox.dev.openshell.localhost:8080/") ); } @@ -864,8 +893,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -877,24 +906,47 @@ mod tests { .with_loopback_service_http(false); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + + #[test] + fn endpoint_url_includes_workspace_prefix_for_non_default() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_server_sans(["*.dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "staging", "my-sandbox", "web").as_deref(), + Some("http://staging--my-sandbox--web.dev.openshell.localhost:8080/") ); } #[test] fn parses_sandbox_service_host() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_sandbox_host_without_service_label() { assert_eq!( - parse_host("my-sandbox.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), String::new())) + parse_host("default--my-sandbox.dev.openshell.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + String::new() + )) ); } @@ -909,16 +961,27 @@ mod tests { #[test] fn parses_sandbox_service_host_with_port() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost:8080", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_alternate_service_routing_domain() { assert_eq!( - parse_host("my-sandbox--web.svc.gateway.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host("default--my-sandbox--web.svc.gateway.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } @@ -930,6 +993,43 @@ mod tests { ); } + #[test] + fn parses_workspace_prefixed_host() { + assert_eq!( + parse_host( + "staging--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "staging".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) + ); + } + + #[test] + fn rejects_consecutive_hyphens_in_workspace() { + assert_eq!( + parse_host( + "team--ml--my-sandbox--web.dev.openshell.localhost", + &config() + ), + None + ); + } + + #[test] + fn rejects_consecutive_hyphens_in_service() { + assert_eq!( + parse_host( + "default--my-sandbox--web--app.dev.openshell.localhost", + &config() + ), + None + ); + } + #[test] fn identifies_sandbox_service_request_from_host_header() { let request = Request::builder() @@ -1101,4 +1201,35 @@ mod tests { assert_eq!(upstream.headers()["sec-websocket-key"], "abc"); assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080"); } + + #[tokio::test] + async fn load_endpoint_uses_workspace_for_lookup() { + let store = crate::persistence::test_store().await; + + let ep = ServiceEndpoint { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "ep-1".to_string(), + name: "my-sandbox--web".to_string(), + created_at_ms: 1_700_000_000_000, + labels: std::collections::HashMap::default(), + resource_version: 0, + workspace: "default".to_string(), + }), + sandbox_id: "sandbox-1".to_string(), + sandbox_name: "my-sandbox".to_string(), + service_name: "web".to_string(), + target_port: 8080, + domain: true, + }; + store.put_message(&ep).await.unwrap(); + + let found = load_endpoint(&store, "default", "my-sandbox", "web").await; + assert!(found.is_ok(), "should find endpoint in correct workspace"); + + let not_found = load_endpoint(&store, "staging", "my-sandbox", "web").await; + assert!( + not_found.is_err(), + "should not find endpoint in wrong workspace" + ); + } } diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 3ef95afbbd..c99b7756b0 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -74,6 +74,9 @@ message DriverSandbox { DriverSandboxSpec spec = 4; // Raw platform-observed status. DriverSandboxStatus status = 5; + // Workspace the sandbox belongs to. Used by drivers to construct + // collision-safe resource names and labels in shared-namespace mode. + string workspace = 6; } // Driver-owned provisioning inputs required to create a sandbox. diff --git a/proto/datamodel.proto b/proto/datamodel.proto index ada4716b11..7708183907 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -30,9 +30,18 @@ message ObjectMeta { // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; - // Opaque key-value metadata that is not used for selectors. - // Annotation keys use the same qualified-key shape as labels, but values may be longer. - map annotations = 6; + // Workspace that owns this resource. Empty is normalized to "default" by the + // gateway. Immutable after creation. + string workspace = 6; +} + +// Workspace resource. A hard isolation boundary for sandboxes, providers, and +// other workspace-scoped resources. +message Workspace { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + // The workspace field in this ObjectMeta is unused (a workspace does not + // belong to another workspace). + ObjectMeta metadata = 1; } // Provider model stored by OpenShell. @@ -48,4 +57,8 @@ message Provider { // Expiration timestamps for credential values, keyed by credential/env var // name. A zero or missing value means the credential does not expire. map credential_expires_at_ms = 5; + // Workspace where this provider's type profile is stored. + // Empty string = platform/global scope. Must be empty or match + // metadata.workspace; cross-workspace references are rejected. + string profile_workspace = 6; } diff --git a/proto/inference.proto b/proto/inference.proto index e982a2700e..0657995829 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -8,29 +8,30 @@ package openshell.inference.v1; import "datamodel.proto"; import "options.proto"; -// Inference service provides cluster inference configuration and bundle delivery. +// Inference service provides workspace-scoped inference route configuration and bundle delivery. service Inference { // Return the resolved inference route bundle for sandbox-local execution. rpc GetInferenceBundle(GetInferenceBundleRequest) returns (GetInferenceBundleResponse); - // Set cluster-level inference configuration. + // Set the inference route for a workspace. // - // This controls how requests sent to `inference.local` are routed. - rpc SetClusterInference(SetClusterInferenceRequest) - returns (SetClusterInferenceResponse); + // This controls how requests sent to `inference.local` are routed + // for sandboxes in the specified workspace. + rpc SetInferenceRoute(SetInferenceRouteRequest) + returns (SetInferenceRouteResponse); - // Get cluster-level inference configuration. - rpc GetClusterInference(GetClusterInferenceRequest) - returns (GetClusterInferenceResponse); + // Get the inference route for a workspace. + rpc GetInferenceRoute(GetInferenceRouteRequest) + returns (GetInferenceRouteResponse); } -// Persisted cluster inference configuration. +// Persisted inference route configuration. // // Only `provider_name` and `model_id` are stored; endpoint, protocols, // credentials, and auth style are resolved from the provider at bundle time. -message ClusterInferenceConfig { +message InferenceRouteConfig { // Provider record name backing this route. string provider_name = 1; // Model identifier to force on generation calls. @@ -39,15 +40,15 @@ message ClusterInferenceConfig { uint64 timeout_secs = 3; } -// Storage envelope for the managed cluster inference route. +// Storage envelope for a workspace-scoped inference route. message InferenceRoute { openshell.datamodel.v1.ObjectMeta metadata = 1; - ClusterInferenceConfig config = 2; + InferenceRouteConfig config = 2; // Monotonic version incremented on every update. uint64 version = 3; } -message SetClusterInferenceRequest { +message SetInferenceRouteRequest { // Provider record name to use for credentials + endpoint mapping. string provider_name = 1; // Model identifier to force on generation calls. @@ -61,6 +62,8 @@ message SetClusterInferenceRequest { bool no_verify = 5; // Per-route request timeout in seconds. 0 means use default (60s). uint64 timeout_secs = 6; + // Target workspace. Empty string defaults to "default". + string workspace = 7; } message ValidatedEndpoint { @@ -68,7 +71,7 @@ message ValidatedEndpoint { string protocol = 2; } -message SetClusterInferenceResponse { +message SetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -80,15 +83,19 @@ message SetClusterInferenceResponse { repeated ValidatedEndpoint validated_endpoints = 6; // Per-route request timeout in seconds that was persisted. uint64 timeout_secs = 7; + // Workspace the route was configured in. + string workspace = 8; } -message GetClusterInferenceRequest { +message GetInferenceRouteRequest { // Route name to query. Empty string defaults to "inference.local" (user-facing). // Use "sandbox-system" for the sandbox system-level inference route. string route_name = 1; + // Target workspace. Empty string defaults to "default". + string workspace = 2; } -message GetClusterInferenceResponse { +message GetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -96,6 +103,8 @@ message GetClusterInferenceResponse { string route_name = 4; // Per-route request timeout in seconds. 0 means default (60s). uint64 timeout_secs = 5; + // Workspace the route belongs to. + string workspace = 6; } message GetInferenceBundleRequest {} diff --git a/proto/openshell.proto b/proto/openshell.proto index b437613b04..10b4e86dc4 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -248,6 +248,31 @@ service OpenShell { // rewritten. rpc RefreshSandboxToken(RefreshSandboxTokenRequest) returns (RefreshSandboxTokenResponse); + + // --------------------------------------------------------------------------- + // Workspace management RPCs + // --------------------------------------------------------------------------- + + // Create a workspace. + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse); + + // Fetch a workspace by name. + rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); + + // List workspaces. + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + + // Delete a workspace by name. + rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); + + // Add a member to a workspace. + rpc AddWorkspaceMember(AddWorkspaceMemberRequest) returns (AddWorkspaceMemberResponse); + + // Remove a member from a workspace. + rpc RemoveWorkspaceMember(RemoveWorkspaceMemberRequest) returns (RemoveWorkspaceMemberResponse); + + // List members of a workspace. + rpc ListWorkspaceMembers(ListWorkspaceMembersRequest) returns (ListWorkspaceMembersResponse); } // IssueSandboxToken request. Empty body; identity is established by the @@ -485,14 +510,16 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; - // Optional annotations for the sandbox (non-selector metadata). - map annotations = 4; + // Workspace for the sandbox. Empty defaults to "default". + string workspace = 4; } // Get sandbox request. message GetSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List sandboxes request. @@ -501,12 +528,18 @@ message ListSandboxesRequest { uint32 offset = 2; // Optional label selector for filtering (format: "key1=value1,key2=value2"). string label_selector = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // List providers attached to a sandbox request. message ListSandboxProvidersRequest { // Sandbox name (canonical lookup key). string sandbox_name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Attach provider to sandbox request. @@ -520,6 +553,8 @@ message AttachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Detach provider from sandbox request. @@ -533,12 +568,16 @@ message DetachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Delete sandbox request. message DeleteSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Sandbox response. @@ -625,6 +664,8 @@ message ExposeServiceRequest { uint32 target_port = 3; // Whether to print/use the browser-facing service URL. bool domain = 4; + // Workspace scope. Empty defaults to "default". + string workspace = 5; } // Request to fetch an exposed sandbox service endpoint. @@ -633,6 +674,8 @@ message GetServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Request to list exposed sandbox service endpoints. @@ -643,6 +686,10 @@ message ListServicesRequest { uint32 limit = 2; // Page offset. uint32 offset = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // Response containing exposed sandbox service endpoints. @@ -656,6 +703,8 @@ message DeleteServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Response for deleting an exposed sandbox service endpoint. @@ -885,17 +934,25 @@ message SandboxStreamWarning { // Create provider request. message CreateProviderRequest { openshell.datamodel.v1.Provider provider = 1; + // Workspace for the provider. Empty defaults to "default". + string workspace = 2; } // Get provider request. message GetProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List providers request. message ListProvidersRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 4; } // Update provider request. @@ -904,11 +961,15 @@ message UpdateProviderRequest { // Optional per-credential expiry timestamps to merge into the provider. // A zero value removes the expiry for that credential. map credential_expires_at_ms = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Delete provider request. message DeleteProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Provider response. @@ -925,11 +986,18 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. When set, returns workspace-scoped + built-in profiles. + // When empty, returns platform-scoped + built-in only. + string workspace = 3; } // Fetch provider type profile request. message GetProviderProfileRequest { string id = 1; + // Workspace scope for two-tier profile resolution. When set, checks + // workspace-scoped profiles first, then platform-scoped, then built-in. + // When empty, checks platform-scoped then built-in only. + string workspace = 2; } // Provider profile payload with optional source metadata for diagnostics. @@ -1072,6 +1140,8 @@ message StoredProviderCredentialRefreshState { message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetProviderRefreshStatusResponse { @@ -1085,6 +1155,8 @@ message ConfigureProviderRefreshRequest { map material = 4 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; + // Workspace scope. Empty defaults to "default". + string workspace = 7; } message ConfigureProviderRefreshResponse { @@ -1094,6 +1166,8 @@ message ConfigureProviderRefreshResponse { message RotateProviderCredentialRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message RotateProviderCredentialResponse { @@ -1103,6 +1177,8 @@ message RotateProviderCredentialResponse { message DeleteProviderRefreshRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message DeleteProviderRefreshResponse { @@ -1159,6 +1235,9 @@ message ListProviderProfilesResponse { // Import custom provider profiles request. message ImportProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). + // When empty, profiles are platform-scoped (Platform Admin). + string workspace = 2; } // Import custom provider profiles response. @@ -1178,6 +1257,9 @@ message UpdateProviderProfilesRequest { uint64 expected_resource_version = 2; // Existing custom provider profile ID to update. The payload ID must match. string id = 3; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 4; } // Update one custom provider profile response. @@ -1190,6 +1272,9 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. Used to check for conflicts against existing profiles + // in the target workspace. + string workspace = 2; } // Lint provider profiles response. @@ -1206,6 +1291,9 @@ message DeleteProviderResponse { // Delete custom provider profile request. message DeleteProviderProfileRequest { string id = 1; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 2; } // Delete custom provider profile response. @@ -1268,13 +1356,8 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; - // Caller-provided annotations associated with a sandbox-scoped update. Values - // must not contain secrets; the gateway treats them as opaque metadata and does - // not interpret or verify their semantics. For policy updates, the gateway - // stores the annotations immutably with the revision and merges them into - // sandbox metadata as a convenience projection. For setting-only updates, it - // only merges them into sandbox metadata. - map annotations = 9; + // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. + string workspace = 9; } message PolicyMergeOperation { @@ -1342,6 +1425,8 @@ message GetSandboxPolicyStatusRequest { uint32 version = 2; // Query global policy revisions instead of a sandbox-scoped one. bool global = 3; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 4; } // Get sandbox policy status response. @@ -1360,6 +1445,8 @@ message ListSandboxPoliciesRequest { uint32 offset = 3; // List global policy revisions instead of sandbox-scoped ones. bool global = 4; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 5; } // List sandbox policies response. @@ -1431,6 +1518,8 @@ message GetSandboxLogsRequest { repeated string sources = 4; // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. string min_level = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } // Batch of log lines pushed from sandbox to server. @@ -1730,6 +1819,8 @@ message SubmitPolicyAnalysisRequest { string name = 4; // Anonymous network activity counters. repeated NetworkActivitySummary network_activity_summaries = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } message SubmitPolicyAnalysisResponse { @@ -1751,6 +1842,8 @@ message GetDraftPolicyRequest { string name = 1; // Optional status filter: "pending", "approved", "rejected", or "" for all. string status_filter = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetDraftPolicyResponse { @@ -1770,6 +1863,8 @@ message ApproveDraftChunkRequest { string name = 1; // Chunk ID to approve. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveDraftChunkResponse { @@ -1787,6 +1882,8 @@ message RejectDraftChunkRequest { string chunk_id = 2; // Optional reason for rejection (fed to LLM context in future analysis). string reason = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message RejectDraftChunkResponse {} @@ -1797,6 +1894,8 @@ message ApproveAllDraftChunksRequest { string name = 1; // Include chunks with security_notes (default false: skips them). bool include_security_flagged = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveAllDraftChunksResponse { @@ -1818,6 +1917,8 @@ message EditDraftChunkRequest { string chunk_id = 2; // The modified rule (replaces existing proposed_rule). openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message EditDraftChunkResponse {} @@ -1828,6 +1929,8 @@ message UndoDraftChunkRequest { string name = 1; // Chunk ID to undo. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message UndoDraftChunkResponse { @@ -1841,6 +1944,8 @@ message UndoDraftChunkResponse { message ClearDraftChunksRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message ClearDraftChunksResponse { @@ -1852,6 +1957,8 @@ message ClearDraftChunksResponse { message GetDraftHistoryRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message DraftHistoryEntry { @@ -1953,3 +2060,116 @@ message StoredDraftChunk { // Operator-supplied free-form rejection text. See PolicyChunk. string rejection_reason = 19; } + +// --------------------------------------------------------------------------- +// Workspace messages +// --------------------------------------------------------------------------- + +// Create workspace request. +message CreateWorkspaceRequest { + // Workspace name. Must be a valid DNS-1123 label. + string name = 1; + // Optional labels for the workspace (key-value metadata). + map labels = 2; +} + +// Create workspace response. +message CreateWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// Get workspace request. +message GetWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Get workspace response. +message GetWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// List workspaces request. +message ListWorkspacesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List workspaces response. +message ListWorkspacesResponse { + repeated openshell.datamodel.v1.Workspace workspaces = 1; +} + +// Delete workspace request. +message DeleteWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Delete workspace response. +message DeleteWorkspaceResponse { + bool deleted = 1; +} + +// --------------------------------------------------------------------------- +// Workspace membership messages +// --------------------------------------------------------------------------- + +// Workspace-scoped role for members. +enum WorkspaceRole { + WORKSPACE_ROLE_UNSPECIFIED = 0; + WORKSPACE_ROLE_USER = 1; + WORKSPACE_ROLE_ADMIN = 2; +} + +// Workspace membership record. +message WorkspaceMember { + openshell.datamodel.v1.ObjectMeta metadata = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role assigned to the principal within the workspace. + WorkspaceRole role = 3; +} + +// Add workspace member request. +message AddWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role to assign. + WorkspaceRole role = 3; +} + +// Add workspace member response. +message AddWorkspaceMemberResponse { + WorkspaceMember member = 1; +} + +// Remove workspace member request. +message RemoveWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal to remove. + string principal_subject = 2; +} + +// Remove workspace member response. +message RemoveWorkspaceMemberResponse { + bool removed = 1; +} + +// List workspace members request. +message ListWorkspaceMembersRequest { + // Workspace name. + string workspace = 1; + uint32 limit = 2; + uint32 offset = 3; +} + +// List workspace members response. +message ListWorkspaceMembersResponse { + repeated WorkspaceMember members = 1; +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 8a5a593334..048e1c3c37 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -329,4 +329,7 @@ message GetSandboxConfigResponse { // Fingerprint for provider credential inputs attached to this sandbox. // Changes when attached provider names or attached provider records change. uint64 provider_env_revision = 8; + // Workspace the sandbox belongs to. Allows the supervisor to learn its + // workspace context for subsequent workspace-scoped RPCs. + string workspace = 9; }