From 77ef1fc042615107b06c6d4cb14f1684805dd54a Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Mon, 13 Jul 2026 08:18:46 -0400 Subject: [PATCH] feat(workspace): add workspace resource model with scoping, membership, and CLI/TUI support Implement Phase 1 of RFC 0011 (multi-player workspace support): - Add Workspace resource with CRUD RPCs (Create, Get, List, Delete) - Add workspace field to ObjectMeta; all workspace-scoped resources (sandbox, provider, service endpoint, SSH session, policy, settings, provider refresh state, inference route) inherit workspace from context - Add membership store with Add/Remove/List WorkspaceMember RPCs - Create default workspace on gateway startup for backwards compat - Shift name uniqueness from (object_type, name) to (object_type, workspace, name) with migration backfill - Add ObjectWorkspace trait with requires_workspace() and debug_assert validation in store write helpers - Add all_workspaces field on list RPCs for cross-workspace visibility - Add cross-workspace list_by_type store method for infrastructure ops - Thread workspace through StoredProviderCredentialRefreshState - Provider profiles use two-tier scoping with independent scope listing (workspace custom + built-in, or platform custom + built-in) - Service endpoint hostnames include workspace prefix for collision avoidance - Add WORKSPACE column to CLI list outputs (kubectl convention) - Add TUI workspace awareness: selector, workspace column, title bar - Clean up membership records on workspace deletion - Workspace-scope inference routes: rename Cluster* RPCs and messages to Route* (SetInferenceRoute, GetInferenceRoute, InferenceRouteConfig), thread workspace through set/get/bundle handlers and CLI commands, derive workspace from sandbox principal for bundle resolution - Add workspace isolation tests for inference routes Signed-off-by: Derek Carr --- crates/openshell-cli/src/completers.rs | 30 +- crates/openshell-cli/src/main.rs | 563 ++++++-- crates/openshell-cli/src/run.rs | 840 +++++++++-- crates/openshell-cli/src/ssh.rs | 41 +- .../tests/ensure_providers_integration.rs | 58 + .../openshell-cli/tests/mtls_integration.rs | 49 + .../tests/provider_commands_integration.rs | 338 ++++- .../sandbox_create_lifecycle_integration.rs | 72 +- .../sandbox_name_fallback_integration.rs | 61 +- crates/openshell-core/src/grpc_client.rs | 3 + crates/openshell-core/src/lib.rs | 4 +- crates/openshell-core/src/metadata.rs | 166 ++- .../postgres/006_add_workspace_column.sql | 13 + .../sqlite/006_add_workspace_column.sql | 13 + .../src/auth/sandbox_methods.rs | 4 +- crates/openshell-server/src/compute/lease.rs | 3 + crates/openshell-server/src/compute/mod.rs | 38 +- crates/openshell-server/src/grpc/auth_rpc.rs | 1 + crates/openshell-server/src/grpc/mod.rs | 122 +- crates/openshell-server/src/grpc/policy.rs | 379 +++-- crates/openshell-server/src/grpc/provider.rs | 860 ++++++++++-- crates/openshell-server/src/grpc/sandbox.rs | 333 ++++- crates/openshell-server/src/grpc/service.rs | 299 +++- .../openshell-server/src/grpc/validation.rs | 1 + crates/openshell-server/src/grpc/workspace.rs | 676 +++++++++ crates/openshell-server/src/inference.rs | 331 ++++- crates/openshell-server/src/lib.rs | 50 +- crates/openshell-server/src/multiplex.rs | 4 +- .../openshell-server/src/persistence/mod.rs | 114 +- .../src/persistence/postgres.rs | 113 +- .../src/persistence/sqlite.rs | 105 +- .../openshell-server/src/persistence/tests.rs | 182 ++- crates/openshell-server/src/policy_store.rs | 12 +- .../openshell-server/src/provider_refresh.rs | 131 +- .../openshell-server/src/service_routing.rs | 169 ++- crates/openshell-server/src/ssh_sessions.rs | 3 +- .../src/supervisor_session.rs | 1 + crates/openshell-server/tests/common/mod.rs | 49 + .../tests/supervisor_relay_integration.rs | 45 + crates/openshell-tui/src/app.rs | 48 + crates/openshell-tui/src/lib.rs | 105 +- crates/openshell-tui/src/ui/mod.rs | 7 + crates/openshell-tui/src/ui/providers.rs | 90 +- crates/openshell-tui/src/ui/sandboxes.rs | 54 +- e2e/rust/Cargo.toml | 5 + e2e/rust/tests/workspace_lifecycle.rs | 174 +++ proto/datamodel.proto | 13 + proto/inference.proto | 41 +- proto/openshell.proto | 230 ++++ rfc/0011-multi-player-design/README.md | 1225 +++++++++++++++++ 50 files changed, 7343 insertions(+), 925 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 create mode 100644 e2e/rust/tests/workspace_lifecycle.rs create mode 100644 rfc/0011-multi-player-design/README.md diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418ae..a419f4bef2 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -11,7 +11,7 @@ use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metad use openshell_core::ObjectName; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; -use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest}; +use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest}; use tonic::service::interceptor::InterceptedService; use tonic::transport::Channel; @@ -39,6 +39,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), + workspace: String::new(), + all_workspaces: false, }) .await .ok()?; @@ -62,6 +64,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, + workspace: String::new(), + all_workspaces: false, }) .await .ok()?; @@ -76,6 +80,30 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { }) } +/// Complete workspace names by querying the active gateway. +pub fn complete_workspace_names(_prefix: &OsStr) -> Vec { + blocking_complete(async { + let (endpoint, gateway_name) = resolve_active_gateway()?; + let mut client = completion_grpc_client(&endpoint, &gateway_name).await?; + let response = client + .list_workspaces(ListWorkspacesRequest { + limit: 200, + offset: 0, + label_selector: String::new(), + }) + .await + .ok()?; + Some( + response + .into_inner() + .workspaces + .into_iter() + .map(|w| CompletionCandidate::new(w.object_name())) + .collect(), + ) + }) +} + fn resolve_active_gateway() -> Option<(String, String)> { let name = std::env::var("OPENSHELL_GATEWAY") .ok() diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66484562d4..9c1d1815e7 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -360,6 +360,16 @@ const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider delete openai "; +const WORKSPACE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m + ws + +\x1b[1mEXAMPLES\x1b[0m + $ openshell workspace create --name staging + $ openshell workspace list + $ openshell workspace get staging + $ openshell workspace delete staging +"; + const GATEWAY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m gw @@ -422,6 +432,17 @@ struct Cli { )] gateway_insecure: bool, + /// Workspace scope for resource operations. + #[arg( + long, + global = true, + env = "OPENSHELL_WORKSPACE", + default_value = "default", + help_heading = "GLOBAL FLAGS", + add = ArgValueCompleter::new(completers::complete_workspace_names) + )] + workspace: String, + /// Increase verbosity (-v, -vv, -vvv). #[arg(short, long, action = clap::ArgAction::Count, global = true, help_heading = "GLOBAL FLAGS")] verbose: u8, @@ -521,6 +542,13 @@ enum Commands { command: Option, }, + /// Manage workspaces. + #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Workspace { + #[command(subcommand)] + command: Option, + }, + // =================================================================== // GATEWAY COMMANDS // =================================================================== @@ -814,6 +842,10 @@ enum ProviderCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "names")] output: OutputFormat, + + /// List providers across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// List available provider profiles. @@ -952,6 +984,10 @@ enum ProviderProfileCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Yaml)] output: OutputFormat, + + /// Target platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Import provider profiles from a file or directory. @@ -964,6 +1000,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to import. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Import as platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Update an existing custom provider profile from a file. @@ -975,6 +1015,10 @@ enum ProviderProfileCommands { /// Profile file to update. #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] file: PathBuf, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, /// Validate provider profile files without registering them. @@ -987,6 +1031,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to lint. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Lint against platform scope (ignores --workspace). + #[arg(long)] + global: bool, }, /// Delete a custom provider profile. @@ -994,6 +1042,10 @@ enum ProviderProfileCommands { Delete { /// Provider profile id. id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, } @@ -1131,7 +1183,7 @@ enum GatewayCommands { #[derive(Subcommand, Debug)] enum InferenceCommands { - /// Set gateway-level inference provider and model. + /// Set workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { /// Provider name. @@ -1157,7 +1209,7 @@ enum InferenceCommands { timeout: u64, }, - /// Update gateway-level inference configuration (partial update). + /// Update workspace-level inference configuration (partial update). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Update { /// Provider name (unchanged if omitted). @@ -1181,7 +1233,7 @@ enum InferenceCommands { timeout: Option, }, - /// Get gateway-level inference provider and model. + /// Get workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Show the system inference route instead of the user-facing route. @@ -1384,6 +1436,10 @@ enum SandboxCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["ids", "names"])] output: OutputFormat, + + /// List sandboxes across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Delete a sandbox by name. @@ -1915,6 +1971,10 @@ enum ServiceCommands { /// Number of endpoints to skip. #[arg(long, default_value_t = 0)] offset: u32, + + /// List services across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Show one exposed sandbox service endpoint. @@ -1940,6 +2000,108 @@ enum ServiceCommands { }, } +#[derive(Subcommand, Debug)] +enum WorkspaceCommands { + /// Create a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Create { + /// Workspace name (DNS-1123 label: lowercase alphanumeric and hyphens, 1-63 chars). + #[arg(long)] + name: String, + + /// Labels to attach to the workspace (KEY=VALUE). + #[arg(long = "label", value_name = "KEY=VALUE")] + labels: Vec, + }, + + /// Fetch a workspace by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Workspace name. + #[arg(add = ArgValueCompleter::new(completers::complete_workspace_names))] + name: String, + }, + + /// List workspaces. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Maximum number of workspaces to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the workspace list. + #[arg(long, default_value_t = 0)] + offset: u32, + + /// Filter by label selector (e.g. "env=staging"). + #[arg(long)] + label_selector: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// Delete a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Workspace name(s) to delete. + #[arg(required = true, add = ArgValueCompleter::new(completers::complete_workspace_names))] + names: Vec, + }, + + /// Manage workspace members. + #[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Member(WorkspaceMemberCommands), +} + +#[derive(Subcommand, Debug)] +enum WorkspaceMemberCommands { + /// Add a member to a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Add { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + + /// Role to assign (user or admin). + #[arg(long)] + role: String, + }, + + /// Remove a member from a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Remove { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + }, + + /// List members of a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// Maximum number of members to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the member list. + #[arg(long, default_value_t = 0)] + offset: u32, + }, +} + #[tokio::main] #[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { @@ -2199,6 +2361,7 @@ async fn main() -> Result<()> { &target_host, target_port, &tls, + &cli.workspace, ) .await?; } @@ -2212,7 +2375,7 @@ async fn main() -> Result<()> { let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls).await?; + run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls, &cli.workspace).await?; if background { eprintln!( "{} Forwarding port {} to sandbox {name} in the background", @@ -2241,24 +2404,42 @@ async fn main() -> Result<()> { target_port, } => { let service = service.unwrap_or_default(); - run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls) - .await?; + run::service_expose( + &ctx.endpoint, + &sandbox, + &service, + target_port, + &cli.workspace, + &tls, + ) + .await?; } ServiceCommands::List { sandbox, limit, offset, + all_workspaces, } => { - run::service_list(&ctx.endpoint, sandbox.as_deref(), limit, offset, &tls) - .await?; + run::service_list( + &ctx.endpoint, + sandbox.as_deref(), + limit, + offset, + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ServiceCommands::Get { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_get(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_get(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } ServiceCommands::Delete { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_delete(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_delete(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } } } @@ -2285,6 +2466,7 @@ async fn main() -> Result<()> { since.as_deref(), &source, &level, + &cli.workspace, &tls, ) .await?; @@ -2343,13 +2525,22 @@ async fn main() -> Result<()> { yes, wait, timeout, + &cli.workspace, &tls, ) .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) - .await?; + run::sandbox_policy_set( + &ctx.endpoint, + &name, + &policy, + wait, + timeout, + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Update { @@ -2379,6 +2570,7 @@ async fn main() -> Result<()> { dry_run, wait, timeout, + &cli.workspace, &tls, ) .await?; @@ -2398,6 +2590,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2409,6 +2602,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2420,10 +2614,12 @@ async fn main() -> Result<()> { global, } => { if global { - run::sandbox_policy_list_global(&ctx.endpoint, limit, &tls).await?; + run::sandbox_policy_list_global(&ctx.endpoint, limit, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2432,7 +2628,8 @@ async fn main() -> Result<()> { "sandbox policy delete is not supported; use --global to remove global policy lock" )); } - run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &cli.workspace, &tls) + .await?; } PolicyCommands::Prove { .. } => unreachable!(), } @@ -2459,7 +2656,8 @@ async fn main() -> Result<()> { run::gateway_settings_get(&ctx.endpoint, json, &tls).await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_settings_get(&ctx.endpoint, &name, json, &tls).await?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &cli.workspace, &tls) + .await?; } } SettingsCommands::Set { @@ -2470,10 +2668,26 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_set(&ctx.endpoint, &key, &value, yes, &tls).await?; + run::gateway_setting_set( + &ctx.endpoint, + &key, + &value, + yes, + &cli.workspace, + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; + run::sandbox_setting_set( + &ctx.endpoint, + &name, + &key, + &value, + &cli.workspace, + &tls, + ) + .await?; } } SettingsCommands::Delete { @@ -2483,10 +2697,18 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, &key, yes, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_delete(&ctx.endpoint, &name, &key, &tls).await?; + run::sandbox_setting_delete( + &ctx.endpoint, + &name, + &key, + &cli.workspace, + &tls, + ) + .await?; } } } @@ -2504,11 +2726,25 @@ async fn main() -> Result<()> { match draft_cmd { DraftCommands::Get { name, status } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_get(&ctx.endpoint, &name, status.as_deref(), &tls).await?; + run::sandbox_draft_get( + &ctx.endpoint, + &name, + status.as_deref(), + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Approve { name, chunk_id } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_approve(&ctx.endpoint, &name, &chunk_id, &tls).await?; + run::sandbox_draft_approve( + &ctx.endpoint, + &name, + &chunk_id, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Reject { name, @@ -2516,8 +2752,15 @@ async fn main() -> Result<()> { reason, } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_reject(&ctx.endpoint, &name, &chunk_id, &reason, &tls) - .await?; + run::sandbox_draft_reject( + &ctx.endpoint, + &name, + &chunk_id, + &reason, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::ApproveAll { name, @@ -2528,6 +2771,7 @@ async fn main() -> Result<()> { &ctx.endpoint, &name, include_security_flagged, + &cli.workspace, &tls, ) .await?; @@ -2535,11 +2779,11 @@ async fn main() -> Result<()> { DraftCommands::Clear { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_clear(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_clear(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } DraftCommands::History { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_history(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_history(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } } } @@ -2564,7 +2808,8 @@ async fn main() -> Result<()> { } => { let route_name = if system { "sandbox-system" } else { "" }; run::gateway_inference_set( - endpoint, &provider, &model, route_name, no_verify, timeout, &tls, + endpoint, &provider, &model, route_name, no_verify, timeout, + &cli.workspace, &tls, ) .await?; } @@ -2583,13 +2828,15 @@ async fn main() -> Result<()> { route_name, no_verify, timeout, + &cli.workspace, &tls, ) .await?; } InferenceCommands::Get { system } => { let route_name = if system { Some("sandbox-system") } else { None }; - run::gateway_inference_get(endpoint, route_name, &tls).await?; + run::gateway_inference_get(endpoint, route_name, &cli.workspace, &tls) + .await?; } } } @@ -2710,6 +2957,7 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, }, + &cli.workspace, &tls, )) .await?; @@ -2743,6 +2991,7 @@ async fn main() -> Result<()> { local, sandbox_dest, &tls, + &cli.workspace, ) .await?; eprintln!("{} Upload complete", "✓".green().bold()); @@ -2754,7 +3003,7 @@ async fn main() -> Result<()> { local.display(), ); } - run::sandbox_sync_up(&ctx.endpoint, &name, local, sandbox_dest, &tls).await?; + run::sandbox_sync_up(&ctx.endpoint, &name, local, sandbox_dest, &tls, &cli.workspace).await?; eprintln!("{} Upload complete", "✓".green().bold()); } SandboxCommands::Download { @@ -2767,7 +3016,7 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); let local_dest = dest.as_deref().unwrap_or("."); eprintln!("Downloading sandbox:{sandbox_path} -> {local_dest}"); - run::sandbox_sync_down(&ctx.endpoint, &name, &sandbox_path, local_dest, &tls) + run::sandbox_sync_down(&ctx.endpoint, &name, &sandbox_path, local_dest, &tls, &cli.workspace) .await?; eprintln!("{} Download complete", "✓".green().bold()); } @@ -2784,7 +3033,8 @@ async fn main() -> Result<()> { } SandboxCommands::Get { name, policy_only } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_get(endpoint, &name, policy_only, &tls).await?; + run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) + .await?; } SandboxCommands::List { limit, @@ -2793,6 +3043,7 @@ async fn main() -> Result<()> { names, selector, output, + all_workspaces, } => { run::sandbox_list( endpoint, @@ -2802,22 +3053,32 @@ async fn main() -> Result<()> { names, selector.as_deref(), output.as_str(), + &cli.workspace, + all_workspaces, &tls, ) .await?; } SandboxCommands::Delete { names, all } => { - run::sandbox_delete(endpoint, &names, all, &tls, &ctx.name).await?; + run::sandbox_delete( + endpoint, + &names, + all, + &cli.workspace, + &tls, + &ctx.name, + ) + .await?; } SandboxCommands::Connect { name, editor } => { let name = resolve_sandbox_name(name, &ctx.name)?; if let Some(editor) = editor.map(Into::into) { run::sandbox_connect_editor( - endpoint, &ctx.name, &name, editor, &tls, + endpoint, &ctx.name, &name, editor, &tls, &cli.workspace, ) .await?; } else { - run::sandbox_connect(endpoint, &name, &tls).await?; + run::sandbox_connect(endpoint, &name, &tls, &cli.workspace).await?; } let _ = save_last_sandbox(&ctx.name, &name); } @@ -2849,6 +3110,7 @@ async fn main() -> Result<()> { tty_override, &env_map, &tls, + &cli.workspace, ) .await?; let _ = save_last_sandbox(&ctx.name, &name); @@ -2863,21 +3125,96 @@ async fn main() -> Result<()> { SandboxCommands::Provider(command) => match command { SandboxProviderCommands::List { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_provider_list(endpoint, &name, &tls).await?; + run::sandbox_provider_list(endpoint, &name, &cli.workspace, &tls) + .await?; } SandboxProviderCommands::Attach { name, provider } => { - run::sandbox_provider_attach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_attach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } SandboxProviderCommands::Detach { name, provider } => { - run::sandbox_provider_detach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_detach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } }, } } } } + + // ----------------------------------------------------------- + // Workspace commands + // ----------------------------------------------------------- + Some(Commands::Workspace { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name); + + match command { + WorkspaceCommands::Create { name, labels } => { + run::workspace_create(endpoint, &name, &labels, &tls).await?; + } + WorkspaceCommands::Get { name } => { + run::workspace_get(endpoint, &name, &tls).await?; + } + WorkspaceCommands::List { + limit, + offset, + label_selector, + output, + } => { + run::workspace_list( + endpoint, + limit, + offset, + label_selector.as_deref().unwrap_or(""), + output.as_str(), + &tls, + ) + .await?; + } + WorkspaceCommands::Delete { names } => { + run::workspace_delete(endpoint, &names, &tls).await?; + } + WorkspaceCommands::Member(command) => match command { + WorkspaceMemberCommands::Add { + workspace, + subject, + role, + } => { + run::workspace_member_add(endpoint, &workspace, &subject, &role, &tls) + .await?; + } + WorkspaceMemberCommands::Remove { workspace, subject } => { + run::workspace_member_remove(endpoint, &workspace, &subject, &tls).await?; + } + WorkspaceMemberCommands::List { + workspace, + limit, + offset, + } => { + run::workspace_member_list(endpoint, &workspace, limit, offset, &tls) + .await?; + } + }, + } + } + Some(Commands::Provider { command: Some(command), }) => { @@ -2905,6 +3242,7 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, &config, + &cli.workspace, &tls, ) .await?; @@ -2918,6 +3256,7 @@ async fn main() -> Result<()> { endpoint, &name, credential_key.as_deref(), + &cli.workspace, &tls, ) .await?; @@ -2942,6 +3281,7 @@ async fn main() -> Result<()> { secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, + &cli.workspace, &tls, ) .await?; @@ -2950,60 +3290,110 @@ async fn main() -> Result<()> { name, credential_key, } => { - run::provider_rotate(endpoint, &name, &credential_key, &tls).await?; + run::provider_rotate( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } ProviderRefreshCommands::Delete { name, credential_key, } => { - run::provider_refresh_delete(endpoint, &name, &credential_key, &tls) - .await?; + run::provider_refresh_delete( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } }, ProviderCommands::Get { name } => { - run::provider_get(endpoint, &name, &tls).await?; + run::provider_get(endpoint, &name, &cli.workspace, &tls).await?; } ProviderCommands::List { limit, offset, names, output, + all_workspaces, } => { - run::provider_list(endpoint, limit, offset, names, output.as_str(), &tls) - .await?; + run::provider_list( + endpoint, + limit, + offset, + names, + output.as_str(), + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ProviderCommands::ListProfiles { output } => { - run::provider_list_profiles(endpoint, output.as_str(), &tls).await?; - } - ProviderCommands::Profile(command) => match command { - ProviderProfileCommands::Export { id, output } => { - run::provider_profile_export(endpoint, &id, output.as_str(), &tls).await?; - } - ProviderProfileCommands::Import { file, from } => { - run::provider_profile_import( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) + run::provider_list_profiles(endpoint, output.as_str(), &cli.workspace, &tls) .await?; + } + ProviderCommands::Profile(command) => { + let profile_workspace = + |global: bool| -> &str { if global { "" } else { &cli.workspace } }; + match command { + ProviderProfileCommands::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Update { id, file, global } => { + run::provider_profile_update( + endpoint, + &id, + &file, + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Delete { id, global } => { + run::provider_profile_delete( + endpoint, + &id, + profile_workspace(global), + &tls, + ) + .await?; + } } - ProviderProfileCommands::Update { id, file } => { - run::provider_profile_update(endpoint, &id, &file, &tls).await?; - } - ProviderProfileCommands::Lint { file, from } => { - run::provider_profile_lint( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) - .await?; - } - ProviderProfileCommands::Delete { id } => { - run::provider_profile_delete(endpoint, &id, &tls).await?; - } - }, + } ProviderCommands::Update { name, from_existing, @@ -3018,12 +3408,13 @@ async fn main() -> Result<()> { &credentials, &config, &credential_expires_at, + &cli.workspace, &tls, ) .await?; } ProviderCommands::Delete { names } => { - run::provider_delete(endpoint, &names, &tls).await?; + run::provider_delete(endpoint, &names, &cli.workspace, &tls).await?; } } } @@ -3086,11 +3477,11 @@ async fn main() -> Result<()> { }; let mut tls = tls.with_gateway_name(&g); apply_auth(&mut tls, &g); - run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls, &cli.workspace).await?; } // Legacy name mode with --server only (no --gateway-name). (_, _, _, Some(srv), None, Some(n)) => { - run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&srv, &n, &tls, &cli.workspace).await?; } _ => { return Err(miette::miette!( @@ -3136,6 +3527,13 @@ async fn main() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Workspace { command: None }) => { + Cli::command() + .find_subcommand_mut("workspace") + .expect("workspace subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -3826,7 +4224,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: OutputFormat::Yaml + output: OutputFormat::Yaml, + .. })) }) if id == "custom-api" )); @@ -3865,7 +4264,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { id, - file: _ + file: _, + .. })) }) if id == "custom-api" )); @@ -3877,7 +4277,8 @@ mod tests { delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id + id, + .. })) }) if id == "custom-api" )); @@ -4829,6 +5230,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox.as_deref(), Some("my-sandbox")); @@ -4848,6 +5250,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox, None); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..f6f887f1ef 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -39,7 +39,7 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, + GetInferenceRouteRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, @@ -50,13 +50,13 @@ use openshell_core::proto::{ ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, + SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; -use openshell_core::{ObjectId, ObjectName}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, @@ -1746,6 +1746,7 @@ async fn finalize_sandbox_create_session( sandbox_name: &str, persist: bool, session_result: Result<()>, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -1754,7 +1755,7 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, tls, gateway).await { + if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { if session_result.is_ok() { return Err(err); } @@ -1821,6 +1822,7 @@ pub async fn sandbox_create( server: &str, gateway_name: &str, config: SandboxCreateConfig<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let SandboxCreateConfig { @@ -1895,6 +1897,7 @@ pub async fn sandbox_create( providers, &inferred_types, auto_providers_override, + workspace, ) .await?; @@ -1928,6 +1931,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, + workspace: workspace.to_string(), }; let response = match client.create_sandbox(request).await { @@ -1977,6 +1981,7 @@ pub async fn sandbox_create( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await { @@ -2225,6 +2230,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2240,6 +2246,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2250,6 +2257,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2267,6 +2275,7 @@ pub async fn sandbox_create( spec, true, // background &effective_tls, + workspace, ) .await?; eprintln!( @@ -2289,6 +2298,7 @@ pub async fn sandbox_create( &sandbox_name, editor, &effective_tls, + workspace, ) .await?; return Ok(()); @@ -2296,12 +2306,13 @@ pub async fn sandbox_create( if command.is_empty() { let connect_result = if persist { - sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await + sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace).await } else { crate::ssh::sandbox_connect_without_exec( &effective_server, &sandbox_name, &effective_tls, + workspace, ) .await }; @@ -2311,6 +2322,7 @@ pub async fn sandbox_create( &sandbox_name, persist, connect_result, + workspace, &effective_tls, gateway_name, ) @@ -2329,6 +2341,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await } else { @@ -2338,6 +2351,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await }; @@ -2347,6 +2361,7 @@ pub async fn sandbox_create( &sandbox_name, persist, exec_result, + workspace, &effective_tls, gateway_name, ) @@ -2572,6 +2587,7 @@ pub async fn sandbox_sync_command( down: Option<&str>, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { match (up, down) { (Some(local_path), None) => { @@ -2584,13 +2600,13 @@ pub async fn sandbox_sync_command( } let dest_display = dest.unwrap_or("~"); eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display); - sandbox_sync_up(server, name, local, dest, tls).await?; + sandbox_sync_up(server, name, local, dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } (None, Some(sandbox_path)) => { let local_dest = dest.unwrap_or("."); eprintln!("Syncing sandbox:{sandbox_path} -> {local_dest}"); - sandbox_sync_down(server, name, sandbox_path, local_dest, tls).await?; + sandbox_sync_down(server, name, sandbox_path, local_dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } _ => { @@ -2611,6 +2627,7 @@ pub async fn sandbox_get( server: &str, name: &str, policy_only: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -2618,6 +2635,7 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -2735,6 +2753,7 @@ pub async fn sandbox_exec_grpc( tty_override: Option, environment: &HashMap, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -2742,6 +2761,7 @@ pub async fn sandbox_exec_grpc( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -2850,11 +2870,12 @@ pub async fn service_forward_tcp( target_host: &str, target_port: u16, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?; let mut client = grpc_client(server, tls).await?; - let sandbox = fetch_ready_sandbox_for_forward(&mut client, name).await?; + let sandbox = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port)) .await @@ -2884,7 +2905,7 @@ pub async fn service_forward_tcp( } _ = health_check.tick() => { - fetch_ready_sandbox_for_forward(&mut client, name).await?; + fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; } accepted = listener.accept() => { @@ -2948,10 +2969,12 @@ async fn create_forward_session_token( async fn fetch_ready_sandbox_for_forward( client: &mut crate::tls::GrpcClient, name: &str, + workspace: &str, ) -> Result { let response = match client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -3345,6 +3368,8 @@ pub async fn sandbox_list( names_only: bool, label_selector: Option<&str>, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3354,6 +3379,12 @@ pub async fn sandbox_list( limit, offset, label_selector: label_selector.unwrap_or("").to_string(), + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .into_diagnostic()?; @@ -3393,14 +3424,34 @@ pub async fn sandbox_list( .unwrap_or(4) .max(4); let created_width = 19; // "YYYY-MM-DD HH:MM:SS" + let ws_width = if all_workspaces { + sandboxes + .iter() + .map(|s| s.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; // Print header - println!( - "{: phase.to_string(), }; let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms)); - println!( - "{: serde_json::Value { }) } -pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_provider_list( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_sandbox_providers(ListSandboxProvidersRequest { sandbox_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -3461,6 +3528,7 @@ pub async fn sandbox_provider_attach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3469,6 +3537,7 @@ pub async fn sandbox_provider_attach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3483,6 +3552,7 @@ pub async fn sandbox_provider_attach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3514,6 +3584,7 @@ pub async fn sandbox_provider_detach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3522,6 +3593,7 @@ pub async fn sandbox_provider_detach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3536,6 +3608,7 @@ pub async fn sandbox_provider_detach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3628,6 +3701,7 @@ pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -3640,6 +3714,8 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), + workspace: workspace.to_string(), + all_workspaces: false, }) .await .into_diagnostic()?; @@ -3668,7 +3744,10 @@ pub async fn sandbox_delete( } let response = client - .delete_sandbox(DeleteSandboxRequest { name: name.clone() }) + .delete_sandbox(DeleteSandboxRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; @@ -3705,6 +3784,7 @@ pub async fn ensure_required_providers( explicit_names: &[String], inferred_types: &[String], auto_providers_override: Option, + workspace: &str, ) -> Result> { if explicit_names.is_empty() && inferred_types.is_empty() { return Ok(Vec::new()); @@ -3723,7 +3803,12 @@ pub async fn ensure_required_providers( let limit = 100_u32; loop { let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: workspace.to_string(), + all_workspaces: false, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -3760,6 +3845,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; // Record the type mapping so the inferred-types pass below @@ -3800,6 +3886,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; } @@ -3821,6 +3908,7 @@ async fn auto_create_provider( auto_providers_override: Option, seen_names: &mut HashSet, configured_names: &mut Vec, + workspace: &str, ) -> Result<()> { eprintln!("Missing provider: {provider_type}"); @@ -3860,7 +3948,7 @@ async fn auto_create_provider( return Ok(()); } - let discovered = discover_existing_provider_data(client, provider_type) + let discovered = discover_existing_provider_data(client, provider_type, workspace) .await .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; let Some(discovered) = discovered else { @@ -3883,12 +3971,14 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }; let response = client.create_provider(request).await.map_err(|status| { @@ -3925,12 +4015,14 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }; match client.create_provider(request).await { @@ -4166,6 +4258,7 @@ pub async fn service_expose( sandbox: &str, service: &str, target_port: u16, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4175,6 +4268,7 @@ pub async fn service_expose( service: service.to_string(), target_port: u32::from(target_port), domain: true, + workspace: workspace.to_string(), }) .await .map_err(service_expose_status_error)? @@ -4212,6 +4306,8 @@ pub async fn service_list( sandbox: Option<&str>, limit: u32, offset: u32, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4220,6 +4316,12 @@ pub async fn service_list( sandbox: sandbox.unwrap_or_default().to_string(), limit, offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .map_err(|status| service_status_error("list services", "sandbox:read", status))? @@ -4234,7 +4336,7 @@ pub async fn service_list( return Ok(()); } - print_service_endpoint_table(&response.services, server); + print_service_endpoint_table(&response.services, server, all_workspaces); Ok(()) } @@ -4242,6 +4344,7 @@ pub async fn service_get( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4249,12 +4352,13 @@ pub async fn service_get( .get_service(GetServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("get service", "sandbox:read", status))? .into_inner(); - print_service_endpoint_table(&[response], server); + print_service_endpoint_table(&[response], server, false); Ok(()) } @@ -4262,6 +4366,7 @@ pub async fn service_delete( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4269,6 +4374,7 @@ pub async fn service_delete( .delete_service(DeleteServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("delete service", "sandbox:write", status))? @@ -4315,11 +4421,19 @@ fn service_status_error(action: &str, required_scope: &str, status: Status) -> m } } -fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_endpoint: &str) { +fn print_service_endpoint_table( + services: &[ServiceEndpointResponse], + gateway_endpoint: &str, + all_workspaces: bool, +) { let rows = services .iter() .filter_map(|response| { let endpoint = response.endpoint.as_ref()?; + let workspace = endpoint + .metadata + .as_ref() + .map_or("", |m| m.workspace.as_str()); let service = service_display_name(&endpoint.service_name).to_string(); let target = format!("127.0.0.1:{}", endpoint.target_port); let url = if response.url.is_empty() { @@ -4327,7 +4441,13 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en } else { service_url_for_gateway(&response.url, gateway_endpoint) }; - Some((endpoint.sandbox_name.clone(), service, target, url)) + Some(( + workspace.to_string(), + endpoint.sandbox_name.clone(), + service, + target, + url, + )) }) .collect::>(); @@ -4335,38 +4455,64 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en return; } + let ws_width = if all_workspaces { + rows.iter() + .map(|(ws, _, _, _, _)| ws.len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let sandbox_width = rows .iter() - .map(|(sandbox, _, _, _)| sandbox.len()) + .map(|(_, sandbox, _, _, _)| sandbox.len()) .max() .unwrap_or(7) .max(7); let service_width = rows .iter() - .map(|(_, service, _, _)| service.len()) + .map(|(_, _, service, _, _)| service.len()) .max() .unwrap_or(7) .max(7); let target_width = rows .iter() - .map(|(_, _, target, _)| target.len()) + .map(|(_, _, _, target, _)| target.len()) .max() .unwrap_or(6) .max(6); - println!( - "{: &str { @@ -4468,10 +4614,12 @@ async fn rollback_provider_create_after_gcloud_adc_failure( provider_name: &str, stage: &str, source: &Status, + workspace: &str, ) -> Result<()> { match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -4538,10 +4686,12 @@ async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Re async fn fetch_provider_profile( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result { let response = client .get_provider_profile(GetProviderProfileRequest { id: provider_type.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| { @@ -4563,9 +4713,10 @@ async fn fetch_provider_profile( async fn discover_existing_provider_data( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result> { if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type).await?; + let profile = fetch_provider_profile(client, provider_type, workspace).await?; let profile = ProviderTypeProfile::from_proto(&profile); let mut discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { @@ -4636,6 +4787,7 @@ pub async fn provider_create( credentials: &[String], from_gcloud_adc: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { provider_create_with_options( @@ -4647,6 +4799,7 @@ pub async fn provider_create( from_gcloud_adc, false, config, + workspace, tls, ) .await @@ -4662,6 +4815,7 @@ pub async fn provider_create_with_options( from_gcloud_adc: bool, runtime_credentials: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { @@ -4692,6 +4846,7 @@ pub async fn provider_create_with_options( let response = client .get_provider_profile(GetProviderProfileRequest { id: profile_id.to_string(), + workspace: workspace.to_string(), }) .await; match response { @@ -4743,7 +4898,8 @@ pub async fn provider_create_with_options( let mut config_map = parse_key_value_pairs(config, "--config")?; if from_existing { - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -4767,10 +4923,10 @@ pub async fn provider_create_with_options( } let allows_empty_credentials = if runtime_credentials { provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type).await?, + &fetch_provider_profile(&mut client, &provider_type, workspace).await?, ) } else { - fetch_provider_profile(&mut client, &provider_type) + fetch_provider_profile(&mut client, &provider_type, workspace) .await .ok() .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) @@ -4805,12 +4961,14 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), r#type: provider_type.clone(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), }), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4840,6 +4998,7 @@ pub async fn provider_create_with_options( "refresh_token".to_string(), ], expires_at_ms: None, + workspace: workspace.to_string(), }) .await { @@ -4848,6 +5007,7 @@ pub async fn provider_create_with_options( &provider_name, "configure", &configure_err, + workspace, ) .await; } @@ -4856,6 +5016,7 @@ pub async fn provider_create_with_options( .rotate_provider_credential(RotateProviderCredentialRequest { provider: provider_name.clone(), credential_key: adc_credential_key, + workspace: workspace.to_string(), }) .await { @@ -4864,6 +5025,7 @@ pub async fn provider_create_with_options( &provider_name, "mint the initial access token for", &rotate_err, + workspace, ) .await; } @@ -4881,11 +5043,17 @@ fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } -pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_get( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4984,17 +5152,29 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { serde_json::Value::Object(obj) } +#[allow(clippy::too_many_arguments)] pub async fn provider_list( server: &str, limit: u32, offset: u32, names_only: bool, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -5018,6 +5198,16 @@ pub async fn provider_list( return Ok(()); } + let ws_width = if all_workspaces { + providers + .iter() + .map(|p| p.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let name_width = providers .iter() .map(|provider| provider.object_name().len()) @@ -5031,33 +5221,61 @@ pub async fn provider_list( .unwrap_or(4) .max(4); - println!( - "{: Result<()> { +pub async fn provider_list_profiles( + server: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_provider_profiles(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5105,9 +5323,10 @@ pub async fn provider_profile_export( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { - let rendered = provider_profile_export_text(server, id, output, tls).await?; + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; if output == "json" { println!("{rendered}"); } else { @@ -5120,11 +5339,15 @@ pub async fn provider_profile_export_text( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result { let mut client = grpc_client(server, tls).await?; let response = client - .get_provider_profile(GetProviderProfileRequest { id: id.to_string() }) + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; let profile = response @@ -5147,6 +5370,7 @@ pub async fn provider_profile_import( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5161,7 +5385,10 @@ pub async fn provider_profile_import( let mut client = grpc_client(server, tls).await?; if !items.is_empty() { let response = client - .import_provider_profiles(ImportProviderProfilesRequest { profiles: items }) + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5188,6 +5415,7 @@ pub async fn provider_profile_update( server: &str, id: &str, file: &Path, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; @@ -5210,6 +5438,7 @@ pub async fn provider_profile_update( profile: Some(item), expected_resource_version, id: id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5229,6 +5458,7 @@ pub async fn provider_profile_lint( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5239,7 +5469,10 @@ pub async fn provider_profile_lint( if !items.is_empty() { let mut client = grpc_client(server, tls).await?; let response = client - .lint_provider_profiles(LintProviderProfilesRequest { profiles: items }) + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5255,10 +5488,18 @@ pub async fn provider_profile_lint( Ok(()) } -pub async fn provider_profile_delete(server: &str, id: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_profile_delete( + server: &str, + id: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .delete_provider_profile(DeleteProviderProfileRequest { id: id.to_string() }) + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5274,6 +5515,7 @@ pub async fn provider_refresh_status( server: &str, name: &str, credential_key: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5281,6 +5523,7 @@ pub async fn provider_refresh_status( .get_provider_refresh_status(GetProviderRefreshStatusRequest { provider: name.to_string(), credential_key: credential_key.unwrap_or_default().to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5331,6 +5574,7 @@ pub struct ProviderRefreshConfigInput<'a> { pub async fn provider_refresh_config( server: &str, input: ProviderRefreshConfigInput<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let strategy = provider_refresh_strategy(input.strategy)?; @@ -5358,6 +5602,7 @@ pub async fn provider_refresh_config( material, secret_material_keys, expires_at_ms: input.credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5378,6 +5623,7 @@ pub async fn provider_rotate( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5385,6 +5631,7 @@ pub async fn provider_rotate( .rotate_provider_credential(RotateProviderCredentialRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5413,6 +5660,7 @@ pub async fn provider_refresh_delete( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5420,6 +5668,7 @@ pub async fn provider_refresh_delete( .delete_provider_refresh(DeleteProviderRefreshRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5693,6 +5942,7 @@ fn truncate_display(value: &str, max_width: usize) -> String { truncated } +#[allow(clippy::too_many_arguments)] pub async fn provider_update( server: &str, name: &str, @@ -5700,6 +5950,7 @@ pub async fn provider_update( credentials: &[String], config: &[String], credential_expires_at: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_existing && !credentials.is_empty() { @@ -5719,6 +5970,7 @@ pub async fn provider_update( let existing = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5727,7 +5979,8 @@ pub async fn provider_update( .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; let provider_type = existing.r#type; - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -5751,6 +6004,7 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), r#type: String::new(), credentials: credential_map, @@ -5758,6 +6012,7 @@ pub async fn provider_update( credential_expires_at_ms: HashMap::new(), }), credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5775,11 +6030,19 @@ pub async fn provider_update( Ok(()) } -pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) -> Result<()> { +pub async fn provider_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; for name in names { let response = client - .delete_provider(DeleteProviderRequest { name: name.clone() }) + .delete_provider(DeleteProviderRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; if response.into_inner().deleted { @@ -5791,6 +6054,334 @@ pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) - Ok(()) } +// --------------------------------------------------------------------------- +// Workspace commands +// --------------------------------------------------------------------------- + +pub async fn workspace_create( + server: &str, + name: &str, + label_args: &[String], + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::CreateWorkspaceRequest; + + let labels = label_args + .iter() + .filter_map(|arg| { + let (k, v) = arg.split_once('=')?; + Some((k.to_string(), v.to_string())) + }) + .collect::>(); + + let mut client = grpc_client(server, tls).await?; + let response = client + .create_workspace(CreateWorkspaceRequest { + name: name.to_string(), + labels, + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!( + "{} Created workspace {}", + "✓".green().bold(), + workspace.object_name().bold() + ); + + Ok(()) +} + +pub async fn workspace_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { + use openshell_core::proto::GetWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .get_workspace(GetWorkspaceRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!("{}", "Workspace:".cyan().bold()); + println!(); + println!(" {} {}", "Name:".dimmed(), workspace.object_name()); + if let Some(meta) = &workspace.metadata { + println!(" {} {}", "Id:".dimmed(), meta.id); + println!( + " {} {}", + "Resource version:".dimmed(), + meta.resource_version + ); + if meta.created_at_ms != 0 { + println!( + " {} {}", + "Created:".dimmed(), + format_epoch_ms(meta.created_at_ms) + ); + } + if !meta.labels.is_empty() { + println!( + " {} {}", + "Labels:".dimmed(), + meta.labels + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + ); + } + } + + Ok(()) +} + +pub async fn workspace_list( + server: &str, + limit: u32, + offset: u32, + label_selector: &str, + output: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::ListWorkspacesRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspaces(ListWorkspacesRequest { + limit, + offset, + label_selector: label_selector.to_string(), + }) + .await + .into_diagnostic()?; + let workspaces = response.into_inner().workspaces; + + if crate::output::print_output_collection(output, &workspaces, workspace_to_json)? { + return Ok(()); + } + + if workspaces.is_empty() { + println!("No workspaces found."); + return Ok(()); + } + + let name_width = workspaces + .iter() + .map(|w| w.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + + println!( + "{:>() + .join(", ") + }); + println!( + "{: Result<()> { + use openshell_core::proto::DeleteWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_workspace(DeleteWorkspaceRequest { name: name.clone() }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted workspace {name}", "✓".green().bold()); + } else { + println!("{} Workspace {name} not found", "!".yellow()); + } + } + Ok(()) +} + +pub async fn workspace_member_add( + server: &str, + workspace: &str, + subject: &str, + role: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{AddWorkspaceMemberRequest, WorkspaceRole}; + + let role_val = match role.to_lowercase().as_str() { + "user" => WorkspaceRole::User, + "admin" => WorkspaceRole::Admin, + _ => { + return Err(miette!( + "invalid role '{}': must be 'user' or 'admin'", + role + )); + } + }; + + let mut client = grpc_client(server, tls).await?; + let response = client + .add_workspace_member(AddWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + role: role_val.into(), + }) + .await + .into_diagnostic()?; + + let member = response + .into_inner() + .member + .ok_or_else(|| miette!("member missing from response"))?; + + println!( + "{} Added {} to workspace {} as {}", + "✓".green().bold(), + member.principal_subject.bold(), + workspace.bold(), + role, + ); + + Ok(()) +} + +pub async fn workspace_member_remove( + server: &str, + workspace: &str, + subject: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::RemoveWorkspaceMemberRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .remove_workspace_member(RemoveWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + }) + .await + .into_diagnostic()?; + + if response.into_inner().removed { + println!( + "{} Removed {} from workspace {}", + "✓".green().bold(), + subject.bold(), + workspace.bold(), + ); + } else { + println!( + "{} Member {} not found in workspace {}", + "!".yellow(), + subject, + workspace, + ); + } + + Ok(()) +} + +pub async fn workspace_member_list( + server: &str, + workspace: &str, + limit: u32, + offset: u32, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{ListWorkspaceMembersRequest, WorkspaceRole}; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspace_members(ListWorkspaceMembersRequest { + workspace: workspace.to_string(), + limit, + offset, + }) + .await + .into_diagnostic()?; + let members = response.into_inner().members; + + if members.is_empty() { + println!("No members found in workspace {workspace}."); + return Ok(()); + } + + let subject_width = members + .iter() + .map(|m| m.principal_subject.len()) + .max() + .unwrap_or(7) + .max(7); + + println!("{: "admin", + Ok(WorkspaceRole::User) => "user", + _ => "unknown", + }; + println!("{: serde_json::Value { + let mut obj = serde_json::Map::new(); + if let Some(meta) = &workspace.metadata { + obj.insert("name".to_string(), serde_json::json!(meta.name)); + obj.insert("id".to_string(), serde_json::json!(meta.id)); + obj.insert( + "resource_version".to_string(), + serde_json::json!(meta.resource_version), + ); + if meta.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(meta.created_at_ms)), + ); + } + if !meta.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(meta.labels)); + } + } + serde_json::Value::Object(obj) +} + +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_set( server: &str, provider_name: &str, @@ -5798,6 +6389,7 @@ pub async fn gateway_inference_set( route_name: &str, no_verify: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let progress = if std::io::stdout().is_terminal() { @@ -5815,13 +6407,14 @@ pub async fn gateway_inference_set( let mut client = grpc_inference_client(server, tls).await?; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider_name.to_string(), model_id: model_id.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs, + workspace: workspace.to_string(), }) .await; @@ -5835,10 +6428,11 @@ pub async fn gateway_inference_set( let label = if configured.route_name == "sandbox-system" { "System inference configured:" } else { - "Gateway inference configured:" + "Inference configured:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -5853,6 +6447,7 @@ pub async fn gateway_inference_set( Ok(()) } +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_update( server: &str, provider_name: Option<&str>, @@ -5860,6 +6455,7 @@ pub async fn gateway_inference_update( route_name: &str, no_verify: bool, timeout_secs: Option, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if provider_name.is_none() && model_id.is_none() && timeout_secs.is_none() { @@ -5872,8 +6468,9 @@ pub async fn gateway_inference_update( // Fetch current config to use as base for the partial update. let current = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5897,13 +6494,14 @@ pub async fn gateway_inference_update( }; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider.to_string(), model_id: model.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs: timeout, + workspace: workspace.to_string(), }) .await; @@ -5917,10 +6515,11 @@ pub async fn gateway_inference_update( let label = if configured.route_name == "sandbox-system" { "System inference updated:" } else { - "Gateway inference updated:" + "Inference updated:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -5938,6 +6537,7 @@ pub async fn gateway_inference_update( pub async fn gateway_inference_get( server: &str, route_name: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_inference_client(server, tls).await?; @@ -5945,8 +6545,9 @@ pub async fn gateway_inference_get( if let Some(name) = route_name { // Show a single route (--system was specified). let response = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5955,19 +6556,20 @@ pub async fn gateway_inference_get( let label = if name == "sandbox-system" { "System inference:" } else { - "Gateway inference:" + "Inference:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); print_timeout(configured.timeout_secs); } else { // Show both routes by default. - print_inference_route(&mut client, "Gateway inference", "").await; + print_inference_route(&mut client, "Inference", "", workspace).await; println!(); - print_inference_route(&mut client, "System inference", "sandbox-system").await; + print_inference_route(&mut client, "System inference", "sandbox-system", workspace).await; } Ok(()) } @@ -5976,10 +6578,12 @@ async fn print_inference_route( client: &mut crate::tls::GrpcInferenceClient, label: &str, route_name: &str, + workspace: &str, ) { match client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -5987,6 +6591,7 @@ async fn print_inference_route( let configured = response.into_inner(); println!("{}", format!("{label}:").cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); @@ -6344,6 +6949,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -6368,6 +6974,7 @@ pub async fn sandbox_policy_set_global( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6390,12 +6997,14 @@ pub async fn sandbox_settings_get( server: &str, name: &str, json: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6551,6 +7160,7 @@ pub async fn gateway_setting_set( key: &str, value: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6567,6 +7177,7 @@ pub async fn gateway_setting_set( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6587,6 +7198,7 @@ pub async fn sandbox_setting_set( name: &str, key: &str, value: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6602,6 +7214,7 @@ pub async fn sandbox_setting_set( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6622,6 +7235,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -6637,6 +7251,7 @@ pub async fn gateway_setting_delete( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6659,6 +7274,7 @@ pub async fn sandbox_setting_delete( server: &str, name: &str, key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -6672,6 +7288,7 @@ pub async fn sandbox_setting_delete( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6702,6 +7319,7 @@ pub async fn sandbox_policy_set( policy_path: &str, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let policy = load_sandbox_policy(Some(policy_path))? @@ -6715,6 +7333,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: 0, global: false, + workspace: workspace.to_string(), }) .await .ok() @@ -6731,6 +7350,7 @@ pub async fn sandbox_policy_set( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6777,6 +7397,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: resp.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6832,6 +7453,7 @@ pub async fn sandbox_policy_update( dry_run: bool, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if dry_run && wait { @@ -6852,6 +7474,7 @@ pub async fn sandbox_policy_update( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6906,6 +7529,7 @@ pub async fn sandbox_policy_update( global: false, merge_operations: plan.merge_operations, expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6952,6 +7576,7 @@ pub async fn sandbox_policy_update( name: name.to_string(), version: response.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6999,6 +7624,7 @@ pub async fn sandbox_policy_get( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut stdout = Vec::new(); @@ -7009,6 +7635,7 @@ pub async fn sandbox_policy_get( version, view, output, + workspace, tls, (&mut stdout, &mut stderr), ) @@ -7027,12 +7654,14 @@ pub async fn sandbox_policy_get( } #[doc(hidden)] +#[allow(clippy::too_many_arguments)] pub async fn sandbox_policy_get_to_writer( server: &str, name: &str, version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7041,8 +7670,10 @@ where E: Write + Send, { if version == 0 { - return sandbox_policy_get_effective_to_writer(server, name, view, output, tls, writers) - .await; + return sandbox_policy_get_effective_to_writer( + server, name, view, output, workspace, tls, writers, + ) + .await; } let (stdout, stderr) = writers; @@ -7053,6 +7684,7 @@ where name: name.to_string(), version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7120,6 +7752,7 @@ async fn sandbox_policy_get_effective_to_writer( name: &str, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7133,6 +7766,7 @@ where let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7234,6 +7868,7 @@ pub async fn sandbox_policy_get_global( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7243,6 +7878,7 @@ pub async fn sandbox_policy_get_global( name: String::new(), version, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7368,6 +8004,7 @@ pub async fn sandbox_policy_list( server: &str, name: &str, limit: u32, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7378,6 +8015,7 @@ pub async fn sandbox_policy_list( limit, offset: 0, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7392,7 +8030,12 @@ pub async fn sandbox_policy_list( Ok(()) } -pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_policy_list_global( + server: &str, + limit: u32, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let resp = client @@ -7401,6 +8044,7 @@ pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptio limit, offset: 0, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7456,6 +8100,7 @@ pub async fn sandbox_logs( since: Option<&str>, sources: &[String], level: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7464,6 +8109,7 @@ pub async fn sandbox_logs( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7528,6 +8174,7 @@ pub async fn sandbox_logs( since_ms, sources: source_filter, min_level: level.to_uppercase(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7590,6 +8237,7 @@ pub async fn sandbox_draft_get( server: &str, name: &str, status_filter: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7598,6 +8246,7 @@ pub async fn sandbox_draft_get( .get_draft_policy(GetDraftPolicyRequest { name: name.to_string(), status_filter: status_filter.unwrap_or("").to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7682,6 +8331,7 @@ pub async fn sandbox_draft_approve( server: &str, name: &str, chunk_id: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7690,6 +8340,7 @@ pub async fn sandbox_draft_approve( .approve_draft_chunk(ApproveDraftChunkRequest { name: name.to_string(), chunk_id: chunk_id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7711,6 +8362,7 @@ pub async fn sandbox_draft_reject( name: &str, chunk_id: &str, reason: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7720,6 +8372,7 @@ pub async fn sandbox_draft_reject( name: name.to_string(), chunk_id: chunk_id.to_string(), reason: reason.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7734,6 +8387,7 @@ pub async fn sandbox_draft_approve_all( server: &str, name: &str, include_security_flagged: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7742,6 +8396,7 @@ pub async fn sandbox_draft_approve_all( .approve_all_draft_chunks(ApproveAllDraftChunksRequest { name: name.to_string(), include_security_flagged, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7759,12 +8414,18 @@ pub async fn sandbox_draft_approve_all( } /// Clear all pending network rules. -pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_clear( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .clear_draft_chunks(ClearDraftChunksRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7780,12 +8441,18 @@ pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> } /// Show network rule history. -pub async fn sandbox_draft_history(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_history( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_draft_history(GetDraftHistoryRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -9724,6 +10391,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, + workspace: String::new(), }; let provider = Provider { diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a5f7b8bcd8..ccff7a8210 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -76,6 +76,7 @@ async fn ssh_session_config( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -83,6 +84,7 @@ async fn ssh_session_config( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -255,8 +257,9 @@ async fn sandbox_connect_with_mode( name: &str, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = ssh_base_command(&session.proxy_command); command @@ -278,16 +281,17 @@ async fn sandbox_connect_with_mode( } /// Connect to a sandbox via SSH. -pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, true).await +pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions, workspace: &str) -> Result<()> { + sandbox_connect_with_mode(server, name, tls, true, workspace).await } pub(crate) async fn sandbox_connect_without_exec( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, false).await + sandbox_connect_with_mode(server, name, tls, false, workspace).await } pub async fn sandbox_connect_editor( @@ -296,12 +300,14 @@ pub async fn sandbox_connect_editor( name: &str, editor: Editor, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // Verify the sandbox exists before writing SSH config / launching the editor. let mut client = grpc_client(server, tls).await?; client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -331,10 +337,11 @@ pub async fn sandbox_forward( spec: &ForwardSpec, background: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { openshell_core::forward::check_port_available(spec)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); command @@ -528,12 +535,13 @@ async fn sandbox_exec_with_mode( tty: bool, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { if command.is_empty() { return Err(miette::miette!("no command provided")); } - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut ssh = ssh_base_command(&session.proxy_command); if tty { @@ -572,8 +580,9 @@ pub async fn sandbox_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, true).await + sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await } pub(crate) async fn sandbox_exec_without_exec( @@ -582,8 +591,9 @@ pub(crate) async fn sandbox_exec_without_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, false).await + sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await } /// What to pack into the tar archive streamed to the sandbox. @@ -754,8 +764,9 @@ async fn ssh_tar_upload( dest_dir: Option<&str>, source: UploadSource, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; // When no explicit destination is given, use the unescaped `$HOME` shell // variable so the remote shell resolves it at runtime. @@ -948,6 +959,7 @@ pub async fn sandbox_sync_up_files( local_path: &Path, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { if files.is_empty() { return Ok(()); @@ -962,6 +974,7 @@ pub async fn sandbox_sync_up_files( archive_prefix: file_list_archive_prefix(local_path), }, tls, + workspace, ) .await } @@ -979,6 +992,7 @@ pub async fn sandbox_sync_up( local_path: &Path, sandbox_path: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // When an explicit destination is given and looks like a file path (does // not end with '/'), split into parent directory + target basename so that @@ -1005,6 +1019,7 @@ pub async fn sandbox_sync_up( tar_name: target_name.into(), }, tls, + workspace, ) .await; } @@ -1032,6 +1047,7 @@ pub async fn sandbox_sync_up( tar_name, }, tls, + workspace, ) .await } @@ -1139,9 +1155,10 @@ pub async fn sandbox_sync_down( sandbox_path: &str, dest: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let sandbox_path = validate_sandbox_source_path(sandbox_path)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let sandbox_path = resolve_sandbox_source_path(&session, &sandbox_path).await?; let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?; @@ -1417,8 +1434,8 @@ fn grpc_server_from_ssh_gateway_url(gateway_url: &str) -> Result { /// and sandbox name instead of pre-created gateway/token credentials. It is /// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it /// creates a fresh session on every invocation. -pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; +pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions, workspace: &str) -> Result<()> { + let session = ssh_session_config(server, name, tls, workspace).await?; sandbox_ssh_proxy( &session.gateway_url, &session.sandbox_id, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4a..a368ac641a 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,6 +63,7 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -349,6 +350,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -583,6 +585,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } // ── test server fixture ────────────────────────────────────────────── @@ -663,6 +714,7 @@ async fn explicit_provider_name_passes_through_when_it_exists() { &["nvidia".to_string()], &[], Some(true), // --auto-providers (should not matter here) + "default", ) .await .expect("should succeed"); @@ -691,6 +743,7 @@ async fn explicit_provider_name_auto_creates_when_valid_type() { &["nvidia".to_string()], &[], Some(true), // --auto-providers to skip interactive prompt + "default", ) .await .expect("should auto-create the provider"); @@ -724,6 +777,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { &["my-custom-thing".to_string()], &[], Some(true), + "default", ) .await .expect_err("should fail for unrecognised provider name"); @@ -755,6 +809,7 @@ async fn inferred_type_auto_creates_provider() { &[], &["claude-code".to_string()], Some(true), // --auto-providers + "default", ) .await .expect("should auto-create the inferred provider"); @@ -784,6 +839,7 @@ async fn no_auto_providers_skips_missing_explicit_provider() { &["nvidia".to_string()], &[], Some(false), // --no-auto-providers + "default", ) .await .expect("should succeed with empty list"); @@ -819,6 +875,7 @@ async fn explicit_and_inferred_providers_combined() { &["nvidia".to_string()], &["claude-code".to_string()], Some(true), + "default", ) .await .expect("should create both providers"); @@ -850,6 +907,7 @@ async fn explicit_and_inferred_deduplicates() { &["nvidia".to_string()], &["nvidia".to_string()], Some(true), + "default", ) .await .expect("should succeed"); diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 3f51dd6044..83b38d9ae4 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -476,6 +476,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } async fn run_server( diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 53b178acbb..a1bb77b7df 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -129,6 +129,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + workspace: String::new(), }), spec: None, status: None, @@ -604,6 +605,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -982,6 +984,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } /// Test fixture: TLS-enabled server with matching client certs. @@ -1063,17 +1114,27 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=abc".to_string()], false, &["profile=dev".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); - run::provider_get(&ts.endpoint, "my-claude", &ts.tls) + run::provider_get(&ts.endpoint, "my-claude", "default", &ts.tls) .await .expect("provider get"); - run::provider_list(&ts.endpoint, 100, 0, false, "table", &ts.tls) - .await - .expect("provider list"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "table", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list"); run::provider_update( &ts.endpoint, @@ -1082,12 +1143,13 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=rotated".to_string()], &["profile=prod".to_string()], &[], + "default", &ts.tls, ) .await .expect("provider update"); - run::provider_delete(&ts.endpoint, &["my-claude".to_string()], &ts.tls) + run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls) .await .expect("provider delete"); } @@ -1096,7 +1158,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() { async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { let ts = run_server().await; - run::provider_list_profiles(&ts.endpoint, "table", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls) .await .expect("provider list-profiles"); } @@ -1114,19 +1176,34 @@ async fn provider_list_json_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test JSON output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1142,19 +1219,34 @@ async fn provider_list_yaml_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test YAML output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "yaml", &ts.tls) - .await - .expect("provider list yaml should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "yaml", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list yaml should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1162,9 +1254,18 @@ async fn provider_list_json_empty() { let ts = run_server().await; // Test JSON output with no providers (verifies it doesn't error on empty list) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json empty should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json empty should succeed"); } #[tokio::test] @@ -1179,6 +1280,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1195,6 +1297,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { secret_material_keys: &["client_secret".to_string()], credential_expires_at_ms: Some(1_767_225_600_000), }, + "default", &ts.tls, ) .await @@ -1203,16 +1306,29 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &ts.endpoint, "my-graph", Some("MS_GRAPH_ACCESS_TOKEN"), + "default", &ts.tls, ) .await .expect("provider refresh status"); - run::provider_rotate(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh rotate"); - run::provider_refresh_delete(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh delete"); + run::provider_rotate( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh rotate"); + run::provider_refresh_delete( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh delete"); let requests = ts.state.refresh_requests.lock().await.clone(); assert_eq!( @@ -1253,6 +1369,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1271,6 +1388,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1312,6 +1430,7 @@ async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_e secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1341,6 +1460,7 @@ async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_uns secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1383,6 +1503,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, true, &[], + "default", &ts.tls, ) .await @@ -1423,6 +1544,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &[], false, &[], + "default", &ts.tls, ) .await @@ -1450,26 +1572,51 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results &["GITHUB_TOKEN=ghp-test".to_string()], false, &[], + "default", &ts.tls, ) .await .expect("provider create"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach is idempotent"); - run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", &ts.tls) + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach"); + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach is idempotent"); + run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls) .await .expect("sandbox provider list"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach is idempotent"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach is idempotent"); let requests = ts.state.sandbox_provider_requests.lock().await.clone(); assert_eq!( @@ -1505,10 +1652,15 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results async fn sandbox_provider_attach_cli_surfaces_server_errors() { let ts = run_server().await; - let err = - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "missing-provider", &ts.tls) - .await - .expect_err("missing provider should fail"); + let err = run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "missing-provider", + "default", + &ts.tls, + ) + .await + .expect_err("missing provider should fail"); assert!( err.to_string().contains("provider not found"), @@ -1549,14 +1701,14 @@ binaries: [/usr/bin/custom] ) .unwrap(); - run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile lint"); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); let exported_yaml = - run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export text"); assert!(exported_yaml.contains("resource_version: 1")); @@ -1567,9 +1719,15 @@ binaries: [/usr/bin/custom] ) .replace("host: api.custom.example", "host: api.updated.example"); std::fs::write(&profile_path, updated_yaml).unwrap(); - run::provider_profile_update(&ts.endpoint, "custom-api", &profile_path, &ts.tls) - .await - .expect("profile update"); + run::provider_profile_update( + &ts.endpoint, + "custom-api", + &profile_path, + "default", + &ts.tls, + ) + .await + .expect("profile update"); assert_eq!( ts.state .profiles @@ -1580,10 +1738,10 @@ binaries: [/usr/bin/custom] .map(|endpoint| endpoint.host.as_str()), Some("api.updated.example") ); - run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export"); - run::provider_list_profiles(&ts.endpoint, "json", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls) .await .expect("provider list-profiles json"); run::provider_create( @@ -1594,6 +1752,7 @@ binaries: [/usr/bin/custom] &["CUSTOM_API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1609,10 +1768,15 @@ binaries: [/usr/bin/custom] .expect("custom provider should be stored"); assert_eq!(provider.r#type, "custom-api"); - run::provider_delete(&ts.endpoint, &["custom-provider".to_string()], &ts.tls) - .await - .expect("custom provider delete"); - run::provider_profile_delete(&ts.endpoint, "custom-api", &ts.tls) + run::provider_delete( + &ts.endpoint, + &["custom-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("custom provider delete"); + run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls) .await .expect("profile delete"); } @@ -1648,6 +1812,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() &[], false, &[], + "default", &ts.tls, ) .await @@ -1681,6 +1846,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( &[], false, &[], + "default", &ts.tls, ) .await @@ -1724,6 +1890,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w &[], false, &[], + "default", &ts.tls, ) .await @@ -1779,6 +1946,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { &[], false, &[], + "default", &ts.tls, ) .await @@ -1822,6 +1990,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin &[], false, &[], + "default", &ts.tls, ) .await @@ -1878,9 +2047,18 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update(&ts.endpoint, "custom-update", true, &[], &[], &[], &ts.tls) - .await - .expect("profile-backed provider update --from-existing"); + run::provider_update( + &ts.endpoint, + "custom-update", + true, + &[], + &[], + &[], + "default", + &ts.tls, + ) + .await + .expect("profile-backed provider update --from-existing"); let provider = ts .state @@ -1929,14 +2107,14 @@ binaries: [/usr/bin/yaml-client] .unwrap(); std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap(); - run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect("profile import --from"); - run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", "default", &ts.tls) .await .expect("custom-yaml should be imported"); - run::provider_profile_export(&ts.endpoint, "custom-json", "json", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-json", "json", "default", &ts.tls) .await .expect("custom-json should be imported"); } @@ -1976,7 +2154,7 @@ binaries: ) .unwrap(); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); @@ -1986,6 +2164,7 @@ binaries: let profile = client .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: String::new(), }) .await .expect("get provider profile") @@ -2020,15 +2199,16 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) - .await - .expect_err("profile import --from should fail on parse errors"); + let err = + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) + .await + .expect_err("profile import --from should fail on parse errors"); assert!( err.to_string().contains("provider profile import failed"), "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("valid profiles should not be partially imported after local parse errors"); } @@ -2051,7 +2231,7 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), &ts.tls) + let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect_err("profile lint --from should fail on parse errors"); assert!( @@ -2059,7 +2239,7 @@ endpoints: "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("lint should not import valid profiles"); } @@ -2076,6 +2256,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() &["INVALID_PAIR".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2101,6 +2282,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &["NAV_GENERIC_TEST_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2112,6 +2294,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { let response = client .get_provider(GetProviderRequest { name: "my-generic".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2136,6 +2319,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() { &["API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2160,6 +2344,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2185,6 +2370,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { &["GOOGLE_VERTEX_AI_TOKEN=token".to_string()], true, &[], + "default", &ts.tls, ) .await @@ -2211,6 +2397,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { &["NAV_EMPTY_ENV_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2236,6 +2423,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { &["NVIDIA_API_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2247,6 +2435,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { let response = client .get_provider(GetProviderRequest { name: "my-nvidia".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2288,6 +2477,7 @@ async fn provider_create_from_gcloud_adc_happy_path() { &[], // no explicit credentials; refresh bootstrap covers it true, // from_gcloud_adc &[], + "default", &ts.tls, ) .await @@ -2373,6 +2563,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2410,6 +2601,7 @@ async fn provider_create_from_gcloud_adc_missing_file() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2443,6 +2635,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred &[], true, &[], + "default", &ts.tls, ) .await @@ -2480,6 +2673,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config &[], true, &[], + "default", &ts.tls, ) .await @@ -2530,6 +2724,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ &[], true, &[], + "default", &ts.tls, ) .await @@ -2578,6 +2773,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate &[], true, &[], + "default", &ts.tls, ) .await @@ -2618,6 +2814,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex &[], false, &[], + "default", &ts.tls, ) .await @@ -2664,6 +2861,7 @@ async fn provider_create_from_gcloud_adc_with_config_keys() { "VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(), "VERTEX_AI_REGION=us-east1".to_string(), ], + "default", &ts.tls, ) .await @@ -2738,6 +2936,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2780,6 +2979,7 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() { &[], true, &[], + "default", &ts.tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index ec8bd53743..056783ac58 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -87,6 +87,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -108,6 +109,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -368,6 +370,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -655,6 +658,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -1129,6 +1181,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1161,6 +1214,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1228,6 +1282,7 @@ async fn sandbox_create_sends_driver_config_json() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1289,6 +1344,7 @@ async fn sandbox_create_sends_gpu_default_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1323,6 +1379,7 @@ async fn sandbox_create_sends_gpu_count_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1358,6 +1415,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1403,6 +1461,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1444,6 +1503,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1477,6 +1537,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1507,6 +1568,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1541,6 +1603,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1574,6 +1637,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1609,6 +1673,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1636,7 +1701,7 @@ async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails( drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - run::sandbox_forward(&server.endpoint, "owned-forward", &spec, true, &tls) + run::sandbox_forward(&server.endpoint, "owned-forward", &spec, true, &tls, "default") .await .expect("background forward should track the owned SSH child without PID discovery"); let record = openshell_core::forward::read_forward_pid("owned-forward", forward_port) @@ -1666,7 +1731,7 @@ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens() drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "foreground-forward", &spec, false, &tls) + let err = run::sandbox_forward(&server.endpoint, "foreground-forward", &spec, false, &tls, "default") .await .expect_err("foreground forward should fail when ssh exits before listener readiness"); let msg = format!("{err}"); @@ -1689,7 +1754,7 @@ async fn sandbox_forward_background_terminates_owned_child_when_listener_never_o drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "unreachable-forward", &spec, true, &tls) + let err = run::sandbox_forward(&server.endpoint, "unreachable-forward", &spec, true, &tls, "default") .await .expect_err("background forward should fail when the listener never opens"); let msg = format!("{err}"); @@ -1748,6 +1813,7 @@ async fn sandbox_create_sends_environment_variables() { ]), ..test_config() }, + "default", &tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 8e799f821e..a29adf2bd0 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -79,6 +79,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Default::default() }), @@ -561,6 +562,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -628,7 +678,7 @@ async fn run_server() -> TestServer { async fn sandbox_get_sends_correct_name() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -645,7 +695,7 @@ async fn sandbox_get_sends_correct_name() { async fn sandbox_get_policy_only_round_trip() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", true, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", true, "default", &ts.tls) .await .expect("sandbox_get with policy_only should succeed"); @@ -671,7 +721,7 @@ async fn sandbox_get_with_persisted_last_sandbox() { assert_eq!(resolved, "persisted-sb"); // Call sandbox_get with the resolved name. - run::sandbox_get(&ts.endpoint, &resolved, false, &ts.tls) + run::sandbox_get(&ts.endpoint, &resolved, false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -695,6 +745,7 @@ async fn policy_get_full_json_cli_prints_policy_payload() { 0, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -743,6 +794,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { 0, run::PolicyGetView::Base, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -784,6 +836,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { 3, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -821,7 +874,7 @@ async fn explicit_name_takes_precedence_over_persisted() { // Persist one name, but supply a different one explicitly. save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed"); - run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 57f6bdd816..450ca7bdf6 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -633,6 +633,7 @@ async fn sync_policy_with_client( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: String::new(), }) .await .into_diagnostic() @@ -816,6 +817,7 @@ impl CachedOpenShellClient { proposed_chunks, network_activity_summaries, analysis_mode: analysis_mode.to_string(), + workspace: String::new(), }) .await .into_diagnostic()?; @@ -838,6 +840,7 @@ impl CachedOpenShellClient { .get_draft_policy(GetDraftPolicyRequest { name: sandbox_name.to_string(), status_filter: status_filter.to_string(), + workspace: String::new(), }) .await .into_diagnostic()?; diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 3212963691..4eb3817760 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -42,7 +42,9 @@ pub use config::{ 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..5a48b8b37c 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 { + false + } +} + // 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-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..b67eee2e98 --- /dev/null +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -0,0 +1,13 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session'); + +-- 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..b67eee2e98 --- /dev/null +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -0,0 +1,13 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session'); + +-- 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/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 76d5e13245..b90841d85a 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -49,10 +49,10 @@ mod tests { "/openshell.v1.OpenShell/ApproveDraftChunk" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/GetClusterInference" + "/openshell.inference.v1.Inference/GetInferenceRoute" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/SetClusterInference" + "/openshell.inference.v1.Inference/SetInferenceRoute" )); } } diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index a2eac70625..1de4de4734 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -101,6 +101,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MustCreate, @@ -140,6 +141,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(record.resource_version), @@ -181,6 +183,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(guard.resource_version), diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3a92cd209a..e34c4ab37a 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -21,6 +21,7 @@ use crate::tracing_bus::TracingLogBus; use futures::{Stream, StreamExt}; use hyper_util::rt::TokioIo; use openshell_core::ComputeDriverKind; +use openshell_core::ObjectWorkspace; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, DeleteSandboxRequest, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, @@ -495,6 +496,7 @@ impl ComputeRuntime { Sandbox::object_type(), &sandbox_id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), None, WriteCondition::MustCreate, @@ -563,13 +565,13 @@ impl ComputeRuntime { } } - pub async fn delete_sandbox(&self, name: &str) -> Result { + pub async fn delete_sandbox(&self, workspace: &str, name: &str) -> Result { let _guard = self.sync_lock.lock().await; // Resolve sandbox ID from name let sandbox = self .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -698,6 +700,7 @@ impl ComputeRuntime { Sandbox::object_type(), &id, &name, + sandbox.object_workspace(), &sandbox.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -754,7 +757,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 1000, 0) + .list_by_type(Sandbox::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -1054,7 +1057,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 500, 0) + .list_by_type(Sandbox::object_type(), 500, 0) .await .map_err(|e| e.to_string())?; @@ -1152,6 +1155,8 @@ impl ComputeRuntime { if supervisor_promoted { ensure_supervisor_ready_status(&mut status, &sandbox_name); } + // TODO: resolve workspace from driver context once multi-workspace is + // fully supported; default workspace is the only option today. let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: incoming.id.clone(), @@ -1159,6 +1164,7 @@ impl ComputeRuntime { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: None, status, @@ -1170,6 +1176,7 @@ impl ComputeRuntime { Sandbox::object_type(), &incoming.id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1375,7 +1382,7 @@ impl ComputeRuntime { if let Err(e) = self .store - .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) .await { warn!( @@ -1388,7 +1395,11 @@ impl ComputeRuntime { } async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str) { - if let Ok(records) = self.store.list(SshSession::object_type(), 1000, 0).await { + if let Ok(records) = self + .store + .list_by_type(SshSession::object_type(), 1000, 0) + .await + { for record in records { if let Ok(session) = SshSession::decode(record.payload.as_slice()) && session.sandbox_id == sandbox_id @@ -2386,6 +2397,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), ..Default::default() }; @@ -2401,6 +2413,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), @@ -3183,6 +3196,7 @@ mod tests { SANDBOX_SETTINGS_OBJECT_TYPE, "settings-sb-1", sandbox.object_name(), + "", br#"{"revision":1,"settings":{}}"#, None, ) @@ -3215,7 +3229,7 @@ mod tests { assert!( runtime .store - .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, "", sandbox.object_name()) .await .unwrap() .is_none() @@ -3490,7 +3504,12 @@ mod tests { runtime.validate_sandbox_create(&sandbox).await.unwrap(); runtime.create_sandbox(sandbox, None).await.unwrap(); - assert!(runtime.delete_sandbox("uds-sandbox").await.unwrap()); + assert!( + runtime + .delete_sandbox("default", "uds-sandbox") + .await + .unwrap() + ); let calls = driver.calls(); assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); @@ -3546,6 +3565,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); @@ -3621,7 +3641,7 @@ mod tests { .expect("should have one successful creation"); let retrieved = runtime .store - .get_message_by_name::("test-concurrent") + .get_message_by_name::("default", "test-concurrent") .await .unwrap(); assert!( diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 88c771bed5..cd8edf63a0 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,6 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c9..4ac60fea41 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -9,40 +9,45 @@ pub mod provider; mod sandbox; mod service; mod validation; +pub mod workspace; use openshell_core::proto::{ - ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, - ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, - ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, - ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderProfileRequest, + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, + AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, + ClearDraftChunksResponse, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, + CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, + CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteServiceRequest, DeleteServiceResponse, DetachSandboxProviderRequest, - DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, - GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, - GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, + EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, + GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetServiceRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, - ImportProviderProfilesResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, - LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, - ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxProvidersRequest, - ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, - ListServicesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, - PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, - RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, - ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, HealthRequest, HealthResponse, + ImportProviderProfilesRequest, ImportProviderProfilesResponse, IssueSandboxTokenRequest, + IssueSandboxTokenResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, + ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, + ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, + ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, + RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, + RotateProviderCredentialResponse, SandboxResponse, SandboxStreamEvent, ServiceEndpointResponse, + ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, + TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, + UpdateConfigResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, + UpdateProviderRequest, WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -669,6 +674,64 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_relay_stream(&self.state.supervisor_sessions, request) .await } + + // --- Workspace management --- + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn create_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_create_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn get_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_get_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspaces( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspaces(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_delete_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn add_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_add_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn remove_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_remove_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspace_members( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspace_members(&self.state, request).await + } } // --------------------------------------------------------------------------- @@ -696,6 +759,7 @@ pub mod test_support { .await .unwrap(), ); + crate::ensure_default_workspace(&store).await.unwrap(); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( Config::new(None).with_database_url("sqlite::memory:?cache=shared"), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc8ff0d2e2..ad98c3c8a1 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,7 +12,9 @@ use crate::ServerState; use crate::auth::principal::Principal; -use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; +use crate::persistence::{ + DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, +}; use crate::policy_store::PolicyStoreExt; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; @@ -519,13 +521,14 @@ fn run_prover_findings( /// with the merged policy the prover validates against. async fn build_credential_set_for_sandbox( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); for name in provider_names { let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? else { @@ -546,7 +549,7 @@ async fn build_credential_set_for_sandbox( profile.clone() } else { let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? + super::provider::get_provider_type_profile(store, workspace, provider_type).await? else { warn!( provider_name = %name, @@ -883,6 +886,7 @@ async fn resolve_proposal_approval_mode( async fn auto_approve_chunk( state: &Arc, sandbox_id: &str, + workspace: &str, sandbox_name: &str, chunk_id: &str, source: &str, @@ -910,7 +914,8 @@ async fn auto_approve_chunk( return Ok(()); } - let (version, hash) = merge_chunk_into_policy(state.store.as_ref(), sandbox_id, &chunk).await?; + let (version, hash) = + merge_chunk_into_policy(state.store.as_ref(), sandbox_id, workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -959,6 +964,7 @@ async fn auto_approve_chunk( // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, + workspace: &str, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -996,7 +1002,8 @@ async fn current_effective_policy_for_sandbox( .map(|spec| spec.providers.clone()) .unwrap_or_default(); let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &provider_names).await?; + profile_provider_policy_layers(state.store.as_ref(), workspace, &provider_names) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1051,11 +1058,12 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu async fn resolve_sandbox_by_name_for_principal( store: &Store, + workspace: &str, principal: &Principal, name: &str, ) -> Result { let sandbox = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -1102,6 +1110,7 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let sandbox_provider_names = sandbox .spec .as_ref() @@ -1151,7 +1160,7 @@ pub(super) async fn handle_get_sandbox_config( if let Err(e) = state .store - .put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash) + .put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash) .await { warn!( @@ -1209,8 +1218,12 @@ pub(super) async fn handle_get_sandbox_config( && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() { - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_layers = profile_provider_policy_layers( + state.store.as_ref(), + &workspace, + &sandbox_provider_names, + ) + .await?; if !provider_layers.is_empty() { let effective_policy = compose_effective_policy(source_policy, &provider_layers); policy_hash = deterministic_policy_hash(&effective_policy); @@ -1221,7 +1234,8 @@ pub(super) async fn handle_get_sandbox_config( let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; + compute_provider_env_revision(state.store.as_ref(), &workspace, &sandbox_provider_names) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1237,6 +1251,7 @@ pub(super) async fn handle_get_sandbox_config( pub(super) async fn compute_provider_env_revision( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1245,7 +1260,7 @@ pub(super) async fn compute_provider_env_revision( for provider_name in provider_names { hasher.update(provider_name.as_bytes()); match store - .get_by_name(Provider::object_type(), provider_name) + .get_by_name(Provider::object_type(), workspace, provider_name) .await .map_err(|e| { Status::internal(format!("fetch provider '{provider_name}' failed: {e}")) @@ -1258,7 +1273,8 @@ pub(super) async fn compute_provider_env_revision( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, &provider.r#type, &mut hasher).await?; + hash_provider_profile_revision(store, workspace, &provider.r#type, &mut hasher) + .await?; let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1286,6 +1302,7 @@ pub(super) async fn compute_provider_env_revision( async fn hash_provider_profile_revision( store: &Store, + workspace: &str, provider_type: &str, hasher: &mut Sha256, ) -> Result<(), Status> { @@ -1299,6 +1316,7 @@ async fn hash_provider_profile_revision( match store .get_by_name( openshell_core::proto::StoredProviderProfile::object_type(), + workspace, provider_type, ) .await @@ -1321,13 +1339,14 @@ async fn hash_provider_profile_revision( async fn profile_provider_policy_layers( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; @@ -1345,7 +1364,7 @@ async fn profile_provider_policy_layers( profile.clone() } else { let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? + super::provider::get_provider_type_profile(store, workspace, provider_type).await? else { warn!( provider_name = %name, @@ -1403,6 +1422,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let spec = sandbox .spec @@ -1410,10 +1430,13 @@ pub(super) async fn handle_get_sandbox_provider_environment( let provider_names = spec.providers; let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &provider_names).await?; - let provider_environment = - super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names) - .await?; + compute_provider_env_revision(state.store.as_ref(), &workspace, &provider_names).await?; + let provider_environment = super::provider::resolve_provider_environment( + state.store.as_ref(), + &workspace, + &provider_names, + ) + .await?; info!( sandbox_id = %sandbox_id, @@ -1458,10 +1481,13 @@ async fn handle_update_config_inner( sandbox_caller: bool, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( state.store.as_ref(), + &workspace, principal .as_ref() .expect("sandbox_caller implies principal"), @@ -1551,6 +1577,7 @@ async fn handle_update_config_inner( .put_policy_revision( &policy_id, GLOBAL_POLICY_SANDBOX_ID, + "", next_version, &payload, &hash, @@ -1655,7 +1682,7 @@ async fn handle_update_config_inner( // Resolve sandbox by name. let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1751,6 +1778,7 @@ async fn handle_update_config_inner( let (version, hash) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, + &workspace, spec.policy.as_ref(), &merge_ops, ) @@ -1881,7 +1909,14 @@ async fn handle_update_config_inner( state .store - .put_policy_revision(&policy_id, &sandbox_id, next_version, &payload, &hash) + .put_policy_revision( + &policy_id, + &sandbox_id, + &workspace, + next_version, + &payload, + &hash, + ) .await .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; @@ -1917,6 +1952,11 @@ pub(super) async fn handle_get_sandbox_policy_status( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let (policy_id, active_version) = if req.global { (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) @@ -1926,7 +1966,7 @@ pub(super) async fn handle_get_sandbox_policy_status( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1968,6 +2008,11 @@ pub(super) async fn handle_list_sandbox_policies( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let policy_id = if req.global { GLOBAL_POLICY_SANDBOX_ID.to_string() @@ -1977,7 +2022,7 @@ pub(super) async fn handle_list_sandbox_policies( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2089,6 +2134,8 @@ pub(super) async fn handle_get_sandbox_logs( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let _workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -2203,12 +2250,19 @@ pub(super) async fn handle_submit_policy_analysis( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); for summary in &req.network_activity_summaries { state @@ -2235,7 +2289,8 @@ pub(super) async fn handle_submit_policy_analysis( // case for the common single-chunk submission shape. If real workloads // surface a problem with batches that interact across chunks, the right // fix is to recompute baseline after each successful auto-approve. - let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; + let current_policy = + current_effective_policy_for_sandbox(state, &workspace, &sandbox, &sandbox_id).await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2254,8 +2309,12 @@ pub(super) async fn handle_submit_policy_analysis( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = - build_credential_set_for_sandbox(state.store.as_ref(), &provider_names_for_creds).await?; + let credential_set = build_credential_set_for_sandbox( + state.store.as_ref(), + &workspace, + &provider_names_for_creds, + ) + .await?; let current_version = state .store @@ -2368,7 +2427,7 @@ pub(super) async fn handle_submit_policy_analysis( .then(|| crate::policy_store::observation_dedup_key(&record)); let effective_id = state .store - .put_draft_chunk(&record, dedup_key.as_deref()) + .put_draft_chunk(&record, dedup_key.as_deref(), &workspace) .await .map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?; accepted += 1; @@ -2422,6 +2481,7 @@ pub(super) async fn handle_submit_policy_analysis( && let Err(err) = auto_approve_chunk( state, &sandbox_id, + &workspace, sandbox.object_name(), &effective_id, &req.analysis_mode, @@ -2469,12 +2529,19 @@ pub(super) async fn handle_get_draft_policy( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); let status_filter = if req.status_filter.is_empty() { @@ -2533,6 +2600,8 @@ async fn handle_approve_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2544,7 +2613,7 @@ async fn handle_approve_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2577,7 +2646,7 @@ async fn handle_approve_draft_chunk_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -2633,6 +2702,8 @@ async fn handle_reject_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2642,7 +2713,7 @@ async fn handle_reject_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2678,7 +2749,8 @@ async fn handle_reject_draft_chunk_inner( if was_approved { require_no_global_policy(state).await?; - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = + remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -2730,6 +2802,8 @@ async fn handle_approve_all_draft_chunks_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2738,7 +2812,7 @@ async fn handle_approve_all_draft_chunks_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2789,7 +2863,7 @@ async fn handle_approve_all_draft_chunks_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, chunk).await?; last_version = version; last_hash = hash; let chunk_summary = summarize_draft_chunk_rule(chunk)?; @@ -2851,6 +2925,8 @@ pub(super) async fn handle_edit_draft_chunk( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2863,7 +2939,7 @@ pub(super) async fn handle_edit_draft_chunk( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2915,6 +2991,8 @@ async fn handle_undo_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2924,7 +3002,7 @@ async fn handle_undo_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2954,7 +3032,7 @@ async fn handle_undo_draft_chunk_inner( "UndoDraftChunk: removing rule from active policy" ); - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; // Clear any prior rejection_reason on the way back to "pending" so an // agent reading the chunk via policy.local cannot see a stale guidance @@ -3000,13 +3078,15 @@ pub(super) async fn handle_clear_draft_chunks( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3036,13 +3116,15 @@ pub(super) async fn handle_get_draft_history( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3505,6 +3587,7 @@ fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, + workspace: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], ) -> Result<(i64, String), Status> { @@ -3545,7 +3628,14 @@ async fn apply_merge_operations_with_retry( let policy_id = uuid::Uuid::new_v4().to_string(); match store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) + .put_policy_revision( + &policy_id, + sandbox_id, + workspace, + next_version, + &payload, + &hash, + ) .await { Ok(()) => { @@ -3592,6 +3682,7 @@ async fn apply_merge_operations_with_retry( pub(super) async fn merge_chunk_into_policy( store: &Store, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) @@ -3601,17 +3692,19 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations).await + apply_merge_operations_with_retry(store, sandbox_id, workspace, None, &operations).await } async fn remove_chunk_from_policy( state: &ServerState, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( state.store.as_ref(), sandbox_id, + workspace, None, &[PolicyMergeOp::RemoveBinary { rule_name: chunk.rule_name.clone(), @@ -3751,7 +3844,7 @@ async fn load_settings_record( name: &str, ) -> Result { let record = store - .get_by_name(object_type, name) + .get_by_name(object_type, "", name) .await .map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?; if let Some(record) = record { @@ -3783,7 +3876,7 @@ async fn save_settings_record( // Update existing with CAS on the version from when it was loaded // Fetch the record to get the stable ID let existing = store - .get_by_name(object_type, name) + .get_by_name(object_type, "", name) .await .map_err(|e| Status::internal(format!("fetch settings for CAS failed: {e}")))? .ok_or_else(|| Status::not_found("settings disappeared since load"))?; @@ -3796,7 +3889,7 @@ async fn save_settings_record( // Single-attempt CAS write store - .put_if(object_type, &id, name, &payload, None, condition) + .put_if(object_type, &id, name, "", &payload, None, condition) .await .map_err(|e| match e { crate::persistence::PersistenceError::Conflict { .. } => { @@ -4063,6 +4156,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4096,6 +4190,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4128,6 +4223,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4163,6 +4259,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4177,6 +4274,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "sandbox-b".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4228,6 +4326,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "missing-sandbox".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4250,6 +4349,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4330,6 +4430,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4356,6 +4457,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) @@ -4399,6 +4501,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4448,9 +4551,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert!(layers.is_empty()); } @@ -4470,6 +4574,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -4491,9 +4596,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert!(layers.is_empty()); } @@ -4514,6 +4620,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4549,9 +4656,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_custom"); @@ -4579,6 +4687,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4600,9 +4709,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example"); @@ -4616,9 +4726,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-github".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-github".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_github"); @@ -4845,6 +4956,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -4896,7 +5008,7 @@ mod tests { let mut updated_profile = stored_profile("api.after.example").profile.unwrap(); updated_profile.resource_version = state .store - .get_message_by_name::("custom-policy") + .get_message_by_name::("default", "custom-policy") .await .unwrap() .unwrap() @@ -4913,6 +5025,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-policy".to_string(), + workspace: "default".to_string(), })), ) .await @@ -4937,7 +5050,7 @@ mod tests { let persisted_provider: Provider = state .store - .get_message_by_name("work-custom") + .get_message_by_name::("default", "work-custom") .await .unwrap() .unwrap(); @@ -5129,6 +5242,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -5171,10 +5285,13 @@ mod tests { .await .unwrap(); - let first = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let first = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); tokio::time::sleep(Duration::from_millis(2)).await; let mut rotated_profile = token_grant_profile("https://auth.example.com/rotated-token") @@ -5182,7 +5299,7 @@ mod tests { .unwrap(); rotated_profile.resource_version = state .store - .get_message_by_name::("custom-token") + .get_message_by_name::("default", "custom-token") .await .unwrap() .unwrap() @@ -5199,15 +5316,19 @@ mod tests { }), expected_resource_version: 0, id: "custom-token".to_string(), + workspace: "default".to_string(), })), ) .await .unwrap(); - let second = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let second = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); assert_ne!( first, second, @@ -5265,6 +5386,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5301,6 +5423,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -5385,6 +5508,7 @@ mod tests { discovery: None, }), }], + workspace: "default".to_string(), }), ) .await @@ -5427,6 +5551,7 @@ mod tests { sandbox_name: "custom-attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5466,6 +5591,7 @@ mod tests { sandbox_name: "custom-attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -5530,6 +5656,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -5619,6 +5746,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5723,6 +5851,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5779,6 +5908,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5797,6 +5927,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -5809,6 +5940,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5826,6 +5958,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -5839,6 +5972,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -5852,6 +5986,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5864,6 +5999,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5879,6 +6015,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -5892,6 +6029,7 @@ mod tests { &state, Request::new(ClearDraftChunksRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5904,6 +6042,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5913,7 +6052,10 @@ mod tests { let history_after_clear = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { name: sandbox_name }), + Request::new(GetDraftHistoryRequest { + name: sandbox_name, + workspace: "default".to_string(), + }), ) .await .unwrap() @@ -5938,6 +6080,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5986,6 +6129,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), + workspace: "default".to_string(), }), ) .await @@ -5996,6 +6140,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6034,6 +6179,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6099,6 +6245,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6149,6 +6296,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6207,6 +6355,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6282,6 +6431,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6352,6 +6502,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6408,6 +6559,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6449,6 +6601,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6508,6 +6661,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6554,6 +6708,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6608,6 +6763,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6647,6 +6803,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6701,6 +6858,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6732,6 +6890,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6785,6 +6944,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6819,6 +6979,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6873,6 +7034,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6906,6 +7068,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6964,6 +7127,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6998,6 +7162,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7111,7 +7276,7 @@ mod tests { }; state .store - .put_draft_chunk(&chunk, None) + .put_draft_chunk(&chunk, None, "default") .await .expect("draft chunk should persist"); @@ -7120,6 +7285,7 @@ mod tests { with_user(Request::new(ApproveDraftChunkRequest { name: sandbox_name.to_string(), chunk_id: chunk.id.clone(), + workspace: "default".to_string(), })), ) .await @@ -7172,6 +7338,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7225,6 +7392,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7268,6 +7436,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7321,6 +7490,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7353,6 +7523,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7406,6 +7577,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7447,6 +7619,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -7485,6 +7658,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7547,6 +7721,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7607,6 +7782,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7714,6 +7890,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7793,6 +7970,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -7861,6 +8039,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7882,6 +8061,7 @@ mod tests { name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), + workspace: "default".to_string(), }), ) .await @@ -7907,6 +8087,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -7962,6 +8143,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8007,6 +8189,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8053,6 +8236,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8063,6 +8247,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8073,6 +8258,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8083,6 +8269,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8121,6 +8308,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8137,6 +8325,7 @@ mod tests { created_at_ms: 1_000_001, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8187,6 +8376,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_a.object_name().to_string(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8200,6 +8390,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8212,6 +8403,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8224,6 +8416,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), + workspace: "default".to_string(), }), ) .await @@ -8235,6 +8428,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8245,6 +8439,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: other_name, chunk_id, + workspace: "default".to_string(), }), ) .await @@ -8463,7 +8658,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk) .await .unwrap(); @@ -8517,6 +8712,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8559,7 +8755,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -8618,6 +8814,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8660,7 +8857,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -8705,6 +8902,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8740,8 +8938,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_allow), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_deny), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -9746,6 +9944,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -9760,7 +9959,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9780,6 +9979,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + workspace: "default".to_string(), }), ) .await @@ -9792,7 +9992,7 @@ mod tests { // Verify the resource_version incremented and policy was backfilled let updated_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9852,6 +10052,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -9865,7 +10066,7 @@ mod tests { let current = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -9905,7 +10106,7 @@ mod tests { let updated_sandbox = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -9955,6 +10156,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -9969,7 +10171,7 @@ mod tests { // Get current version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9989,6 +10191,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + workspace: "default".to_string(), }), ) .await @@ -10006,7 +10209,7 @@ mod tests { // Verify the sandbox was not modified (policy still None) let unchanged = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -10036,6 +10239,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -10050,7 +10254,7 @@ mod tests { // All three clients fetch the sandbox and see the same version let initial = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -10074,6 +10278,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, + workspace: "default".to_string(), }), ) .await @@ -10106,7 +10311,7 @@ mod tests { // Final sandbox should have resource_version = initial_version + 1 and policy backfilled let final_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..4f7fc17740 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -65,6 +65,7 @@ impl ProviderEnvironment { pub(super) async fn create_provider_record( store: &Store, + workspace: &str, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -78,6 +79,7 @@ pub(super) async fn create_provider_record( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }); } @@ -98,7 +100,7 @@ pub(super) async fn create_provider_record( return Err(Status::invalid_argument("provider.type is required")); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, &provider.r#type).await? + && !provider_type_allows_empty_credentials(store, workspace, &provider.r#type).await? { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -121,6 +123,7 @@ pub(super) async fn create_provider_record( Provider::object_type(), &provider_id, provider.object_name(), + workspace, &provider.encode_to_vec(), None, WriteCondition::MustCreate, @@ -144,13 +147,17 @@ pub(super) async fn create_provider_record( Ok(redact_provider_credentials(provider)) } -pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn get_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found")) @@ -159,11 +166,12 @@ pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result Result, Status> { let providers: Vec = store - .list_messages(limit, offset) + .list_messages(workspace, limit, offset) .await .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; @@ -175,6 +183,7 @@ pub(super) async fn list_provider_records( pub(super) async fn update_provider_record( store: &Store, + workspace: &str, provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -188,7 +197,7 @@ pub(super) async fn update_provider_record( // Resolve provider ID from name for CAS update let existing = store - .get_message_by_name::(provider.object_name()) + .get_message_by_name::(workspace, provider.object_name()) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?; @@ -230,7 +239,7 @@ pub(super) async fn update_provider_record( // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes(store, &candidate).await?; + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await?; // Serialize labels for storage let labels_map = candidate.object_labels(); @@ -252,6 +261,7 @@ pub(super) async fn update_provider_record( Provider::object_type(), candidate.object_id(), candidate.object_name(), + workspace, &candidate.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -281,20 +291,24 @@ pub(super) async fn update_provider_record( Ok(redact_provider_credentials(candidate)) } -pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn delete_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { return Ok(false); }; - let blocking_sandboxes = sandboxes_using_provider(store, name).await?; + let blocking_sandboxes = sandboxes_using_provider(store, workspace, name).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider '{name}' is attached to sandbox(es): {}", @@ -306,7 +320,7 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< .await?; store - .delete_by_name(Provider::object_type(), name) + .delete_by_name(Provider::object_type(), workspace, name) .await .map_err(|e| Status::internal(format!("delete provider failed: {e}"))) } @@ -316,7 +330,7 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< /// value in the output, `None` skips it. /// /// This is the shared pagination kernel used by all sandbox-scan helpers. -async fn scan_sandboxes(store: &Store, mut f: F) -> Result, Status> +async fn scan_sandboxes(store: &Store, workspace: &str, mut f: F) -> Result, Status> where F: FnMut(Sandbox) -> Option, { @@ -324,7 +338,7 @@ where let mut offset = 0u32; loop { let records = store - .list(Sandbox::object_type(), 1000, offset) + .list(Sandbox::object_type(), workspace, 1000, offset) .await .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; if records.is_empty() { @@ -349,10 +363,11 @@ where async fn sandboxes_using_provider( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - let mut names = scan_sandboxes(store, |sandbox| { + let mut names = scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox.object_name().to_string()) @@ -368,10 +383,11 @@ async fn sandboxes_using_provider( async fn sandboxes_using_provider_records( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - scan_sandboxes(store, |sandbox| { + scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox) @@ -433,6 +449,7 @@ fn merge_i64_map( /// providers so one provider cannot silently overwrite another provider's token. pub(super) async fn resolve_provider_environment( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { if provider_names.is_empty() { @@ -442,12 +459,13 @@ pub(super) async fn resolve_provider_environment( let mut env = std::collections::HashMap::new(); let mut expires = std::collections::HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, provider_names, None, now_ms).await?; + validate_provider_environment_keys_unique_at(store, workspace, provider_names, None, now_ms) + .await?; let registry = openshell_providers::ProviderRegistry::new(); for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; @@ -495,7 +513,7 @@ pub(super) async fn resolve_provider_environment( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials(store, provider_names).await?, + dynamic_credentials: resolve_dynamic_credentials(store, workspace, provider_names).await?, }) } @@ -506,6 +524,7 @@ pub(super) async fn resolve_provider_environment( /// host, port, endpoint path, and provider credential identity. pub(super) async fn resolve_dynamic_credentials( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -516,7 +535,7 @@ pub(super) async fn resolve_dynamic_credentials( for provider_name in provider_names { let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| { Status::internal(format!("failed to fetch provider '{provider_name}': {e}")) @@ -527,7 +546,7 @@ pub(super) async fn resolve_dynamic_credentials( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { continue; }; @@ -816,10 +835,12 @@ fn endpoint_path_matches(pattern: &str, path: &str) -> bool { pub async fn validate_provider_environment_keys_unique( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, + workspace, provider_names, None, crate::persistence::current_time_ms(), @@ -829,6 +850,7 @@ pub async fn validate_provider_environment_keys_unique( pub async fn validate_provider_credential_key_available_for_attached_sandboxes( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -838,21 +860,23 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes( .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); candidate.credential_expires_at_ms.remove(credential_key); - validate_provider_update_against_attached_sandboxes(store, &candidate).await + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await } pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, + workspace: &str, provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); - for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { + for sandbox in sandboxes_using_provider_records(store, workspace, &provider_name).await? { let sandbox_name = sandbox.object_name().to_string(); let Some(spec) = sandbox.spec.as_ref() else { continue; }; validate_provider_environment_keys_unique_at( store, + workspace, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -870,6 +894,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( async fn validate_provider_environment_keys_unique_at( store: &Store, + workspace: &str, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -880,7 +905,7 @@ async fn validate_provider_environment_keys_unique_at( let provider = match candidate_provider { Some(candidate) if candidate.object_name() == name.as_str() => candidate.clone(), _ => store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| { @@ -899,7 +924,8 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + dynamic_bindings + .extend(dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -917,11 +943,12 @@ struct DynamicTokenGrantBinding { async fn dynamic_token_grant_bindings_for_provider( store: &Store, + workspace: &str, provider: &Provider, ) -> Result, Status> { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = get_provider_type_profile(store, workspace, profile_id).await? else { return Ok(Vec::new()); }; Ok(dynamic_token_grant_bindings_for_profile( @@ -1152,7 +1179,9 @@ pub(super) async fn handle_create_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); - let Some(provider) = req.provider else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", LifecycleOperation::Create, @@ -1160,8 +1189,11 @@ pub(super) async fn handle_create_provider( ); return Err(Status::invalid_argument("provider is required")); }; + if let Some(metadata) = provider.metadata.as_mut() { + metadata.workspace.clone_from(&workspace); + } let provider_type = provider.r#type.clone(); - let result = create_provider_record(state.store.as_ref(), provider).await; + let result = create_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1188,8 +1220,10 @@ pub(super) async fn handle_get_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider = get_provider_record(state.store.as_ref(), &name).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let provider = get_provider_record(state.store.as_ref(), &workspace, &req.name).await?; Ok(Response::new(ProviderResponse { provider: Some(provider), @@ -1201,8 +1235,25 @@ pub(super) async fn handle_list_providers( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let providers = list_provider_records(state.store.as_ref(), limit, request.offset).await?; + + let providers = if request.all_workspaces { + let all: Vec = state + .store + .list_all_messages(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; + all.into_iter().map(redact_provider_credentials).collect() + } else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + list_provider_records(state.store.as_ref(), &workspace, limit, request.offset).await? + }; Ok(Response::new(ListProvidersResponse { providers })) } @@ -1218,9 +1269,11 @@ pub(super) async fn handle_list_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let mut profiles = merged_provider_profiles(state.store.as_ref()).await?; + let mut profiles = merged_provider_profiles(state.store.as_ref(), &workspace).await?; profiles.sort_by(|left, right| left.id.cmp(&right.id)); let profiles = profiles .into_iter() @@ -1236,9 +1289,12 @@ pub(super) async fn handle_get_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; - let profile = get_provider_type_profile(state.store.as_ref(), &id) + let profile = get_provider_type_profile(state.store.as_ref(), &workspace, &id) .await? .ok_or_else(|| Status::not_found("provider profile not found"))? .to_proto(); @@ -1253,14 +1309,23 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &workspace, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "import").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &workspace, + &profiles, + "import", + ) + .await?, ); } @@ -1274,13 +1339,14 @@ pub(super) async fn handle_import_provider_profiles( let mut imported = Vec::with_capacity(profiles.len()); for (_, profile) in profiles { - let mut stored = stored_provider_profile(profile.to_proto()); + let mut stored = stored_provider_profile_for_workspace(profile.to_proto(), &workspace); let result = state .store .put_if( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1309,12 +1375,15 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let items = request.profile.into_iter().collect::>(); let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &profiles, &target_id).await?, + profile_update_target_diagnostics(state.store.as_ref(), &workspace, &profiles, &target_id) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1342,7 +1411,13 @@ pub(super) async fn handle_update_provider_profiles( }; if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "update").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &workspace, + &profiles, + "update", + ) + .await?, ); } @@ -1361,7 +1436,7 @@ pub(super) async fn handle_update_provider_profiles( .ok_or_else(|| Status::internal("validated provider profile update is missing"))?; let mut stored = state .store - .get_message_by_name::(&target_id) + .get_message_by_name::(&workspace, &target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .ok_or_else(|| Status::not_found("provider profile not found"))?; @@ -1390,6 +1465,7 @@ pub(super) async fn handle_update_provider_profiles( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -1416,7 +1492,7 @@ pub(super) async fn handle_lint_provider_profiles( let request = request.into_inner(); let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), "", &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1430,7 +1506,10 @@ pub(super) async fn handle_delete_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; if get_default_profile(&id).is_some() { return Err(Status::failed_precondition( @@ -1441,14 +1520,14 @@ pub(super) async fn handle_delete_provider_profile( let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let existing = state .store - .get_message_by_name::(&id) + .get_message_by_name::(&workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))?; if existing.is_none() { return Err(Status::not_found("provider profile not found")); } - let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &id).await?; + let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &workspace, &id).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider profile '{id}' is in use by sandboxes: {}", @@ -1458,7 +1537,7 @@ pub(super) async fn handle_delete_provider_profile( let deleted = state .store - .delete_by_name(StoredProviderProfile::object_type(), &id) + .delete_by_name(StoredProviderProfile::object_type(), &workspace, &id) .await .map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?; @@ -1467,6 +1546,7 @@ pub(super) async fn handle_delete_provider_profile( pub(super) async fn get_provider_type_profile( store: &Store, + workspace: &str, id: &str, ) -> Result, Status> { let Some(id) = normalize_profile_id(id) else { @@ -1476,7 +1556,7 @@ pub(super) async fn get_provider_type_profile( return Ok(Some(profile.clone())); } let profile = store - .get_message_by_name::(&id) + .get_message_by_name::(workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .and_then(|stored| { @@ -1493,10 +1573,11 @@ pub(super) async fn get_provider_type_profile( async fn provider_refresh_defaults( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, ) -> Result, Status> { - let Some(profile) = get_provider_type_profile(store, &provider.r#type).await? else { + let Some(profile) = get_provider_type_profile(store, workspace, &provider.r#type).await? else { return Ok(None); }; Ok(profile @@ -1539,18 +1620,22 @@ fn validate_refresh_material( async fn provider_type_allows_empty_credentials( store: &Store, + workspace: &str, provider_type: &str, ) -> Result { - let Some(profile) = get_provider_type_profile(store, provider_type).await? else { + let Some(profile) = get_provider_type_profile(store, workspace, provider_type).await? else { return Ok(false); }; Ok(profile.allows_empty_provider_credentials()) } -async fn merged_provider_profiles(store: &Store) -> Result, Status> { +async fn merged_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { let mut profiles = default_profiles().to_vec(); profiles.extend( - custom_provider_profiles(store) + custom_provider_profiles(store, workspace) .await? .into_iter() .filter_map(|stored| { @@ -1566,9 +1651,12 @@ async fn merged_provider_profiles(store: &Store) -> Result Result, Status> { +async fn custom_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { let profiles: Vec = store - .list_messages(10_000, 0) + .list_messages(workspace, 10_000, 0) .await .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; Ok(profiles) @@ -1625,6 +1713,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1643,7 +1732,7 @@ async fn profile_conflict_diagnostics( continue; } if store - .get_message_by_name::(&id) + .get_message_by_name::(workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_some() @@ -1662,6 +1751,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1693,7 +1783,7 @@ async fn profile_update_target_diagnostics( return Ok(diagnostics); } if store - .get_message_by_name::(target_id) + .get_message_by_name::(workspace, target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_none() @@ -1725,6 +1815,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1740,7 +1831,7 @@ async fn profile_attached_sandbox_diagnostics( return Ok(Vec::new()); } - let sandboxes = scan_sandboxes(store, |sandbox| { + let sandboxes = scan_sandboxes(store, workspace, |sandbox| { sandbox .spec .as_ref() @@ -1757,7 +1848,7 @@ async fn profile_attached_sandbox_diagnostics( for provider_name in &spec.providers { let Some(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}")))? else { @@ -1775,7 +1866,9 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + bindings.extend( + dynamic_token_grant_bindings_for_provider(store, workspace, &provider).await?, + ); } } @@ -1801,7 +1894,10 @@ async fn profile_attached_sandbox_diagnostics( Ok(diagnostics) } -fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { +fn stored_provider_profile_for_workspace( + profile: ProviderProfile, + workspace: &str, +) -> StoredProviderProfile { use crate::persistence::current_time_ms; let now_ms = current_time_ms(); let profile = profile_storage_payload(profile); @@ -1812,11 +1908,17 @@ fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), profile: Some(profile), } } +#[cfg(test)] +fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + stored_provider_profile_for_workspace(profile, "default") +} + fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { profile.resource_version = 0; profile @@ -1853,10 +1955,14 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile(store: &Store, profile_id: &str) -> Result, Status> { +async fn sandboxes_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { // Collect all sandboxes that reference at least one provider — pagination // is handled by `scan_sandboxes`; the async provider lookup happens below. - let candidates = scan_sandboxes(store, |sandbox| { + let candidates = scan_sandboxes(store, workspace, |sandbox| { let has_providers = sandbox .spec .as_ref() @@ -1870,7 +1976,7 @@ async fn sandboxes_using_profile(store: &Store, profile_id: &str) -> Result(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { @@ -1892,6 +1998,8 @@ pub(super) async fn handle_update_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1904,7 +2012,7 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let result = update_provider_record(state.store.as_ref(), provider).await; + let result = update_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1932,12 +2040,14 @@ pub(super) async fn handle_get_provider_refresh_status( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider.trim().is_empty() { return Err(Status::invalid_argument("provider is required")); } let provider = state .store - .get_message_by_name::(&request.provider) + .get_message_by_name::(&workspace, &request.provider) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; @@ -1951,6 +2061,7 @@ pub(super) async fn handle_get_provider_refresh_status( } else { crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), request.credential_key.trim(), ) @@ -1972,6 +2083,8 @@ pub(super) async fn handle_configure_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2054,18 +2167,20 @@ pub(super) async fn handle_configure_provider_refresh( let provider = state .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::not_found("provider not found"))?; validate_provider_credential_key_available_for_attached_sandboxes( state.store.as_ref(), + &workspace, &provider, credential_key, ) .await?; let refresh_defaults = - provider_refresh_defaults(state.store.as_ref(), &provider, credential_key).await?; + provider_refresh_defaults(state.store.as_ref(), &workspace, &provider, credential_key) + .await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2108,6 +2223,7 @@ pub(super) async fn handle_configure_provider_refresh( } let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2120,6 +2236,7 @@ pub(super) async fn handle_configure_provider_refresh( }); let mut state_record = crate::provider_refresh::new_refresh_state( &provider, + &workspace, credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, @@ -2146,6 +2263,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2155,7 +2273,7 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2170,6 +2288,8 @@ pub(super) async fn handle_rotate_provider_credential( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2180,6 +2300,7 @@ pub(super) async fn handle_rotate_provider_credential( } let refresh_state = crate::provider_refresh::refresh_provider_credential( state.store.as_ref(), + &workspace, provider_name, credential_key, ) @@ -2197,6 +2318,8 @@ pub(super) async fn handle_delete_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2207,18 +2330,20 @@ pub(super) async fn handle_delete_provider_refresh( } let provider = state .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::not_found("provider not found"))?; let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) .await?; let deleted_refresh_state = crate::provider_refresh::delete_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2241,6 +2366,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2250,7 +2376,7 @@ pub(super) async fn handle_delete_provider_refresh( 0, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -2262,9 +2388,12 @@ pub(super) async fn handle_delete_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider_profile = provider_profile_for_name(state.store.as_ref(), &name).await; - let result = delete_provider_record(state.store.as_ref(), &name).await; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let name = req.name; + let provider_profile = provider_profile_for_name(state.store.as_ref(), &workspace, &name).await; + let result = delete_provider_record(state.store.as_ref(), &workspace, &name).await; match result { Ok(deleted) => { let outcome = TelemetryOutcome::from_success(deleted); @@ -2303,9 +2432,13 @@ fn emit_provider_profile_lifecycle( openshell_core::telemetry::emit_provider_lifecycle(operation, outcome, provider_profile); } -async fn provider_profile_for_name(store: &Store, name: &str) -> Option { +async fn provider_profile_for_name( + store: &Store, + workspace: &str, + name: &str, +) -> Option { store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .ok() .flatten() @@ -2506,6 +2639,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -2519,6 +2653,7 @@ mod tests { ) -> Provider { create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2526,6 +2661,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -2548,6 +2684,7 @@ mod tests { let err = validate_provider_environment_keys_unique( store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -2576,6 +2713,7 @@ mod tests { validate_provider_environment_keys_unique( store, + "default", &["provider-default".to_string(), "provider-admin".to_string()], ) .await @@ -2591,6 +2729,7 @@ mod tests { create_empty_token_grant_provider(store, "provider-existing", "grant-existing").await; create_provider_record( store, + "default", provider_with_values("provider-candidate", "grant-new"), ) .await @@ -2603,6 +2742,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2632,6 +2772,7 @@ mod tests { profile: Some(profile), source: "grant-new.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2659,6 +2800,7 @@ mod tests { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2691,7 +2833,7 @@ mod tests { state.store.put_message(&stored).await.unwrap(); let before: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2713,6 +2855,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2727,7 +2870,7 @@ mod tests { ); let after: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2757,6 +2900,7 @@ mod tests { }), expected_resource_version: 0, id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2778,6 +2922,7 @@ mod tests { }), expected_resource_version: 0, id: "missing-custom".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2809,6 +2954,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2831,6 +2977,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2858,7 +3005,7 @@ mod tests { .unwrap(); let profile_a_version = state .store - .get_message_by_name::("profile-a") + .get_message_by_name::("default", "profile-a") .await .unwrap() .unwrap() @@ -2879,6 +3026,7 @@ mod tests { }), expected_resource_version: 0, id: "profile-a".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2894,7 +3042,7 @@ mod tests { })); let stored_b: StoredProviderProfile = state .store - .get_message_by_name("profile-b") + .get_message_by_name("default", "profile-b") .await .unwrap() .unwrap(); @@ -2918,6 +3066,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2933,7 +3082,7 @@ mod tests { let mut profile = custom_profile("grant-updated"); profile.resource_version = store - .get_message_by_name::("grant-updated") + .get_message_by_name::("default", "grant-updated") .await .unwrap() .unwrap() @@ -2958,6 +3107,7 @@ mod tests { }), expected_resource_version: 0, id: "grant-updated".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2980,6 +3130,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: [ @@ -3069,6 +3220,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -3128,6 +3280,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3174,6 +3327,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3191,6 +3345,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "generic".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3208,6 +3363,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3222,6 +3378,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3238,6 +3395,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3258,6 +3416,7 @@ mod tests { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3285,6 +3444,7 @@ mod tests { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3298,6 +3458,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-llm".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3328,6 +3489,7 @@ mod tests { source: "case.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3355,6 +3517,7 @@ mod tests { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3364,6 +3527,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3377,6 +3541,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3405,6 +3570,7 @@ mod tests { source: "bulk-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3422,7 +3588,10 @@ mod tests { for id in ["bulk-one", "bulk-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3470,6 +3639,7 @@ mod tests { }), source: "advanced-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3482,6 +3652,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3525,6 +3696,7 @@ mod tests { source: "lint-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3541,7 +3713,10 @@ mod tests { for id in ["lint-one", "lint-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3559,6 +3734,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3568,6 +3744,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3576,6 +3753,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", provider_with_values("custom-provider", "custom-api"), ) .await @@ -3589,6 +3767,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3603,6 +3782,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3617,6 +3797,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3624,6 +3805,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3652,6 +3834,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3666,6 +3849,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3676,7 +3860,7 @@ mod tests { let provider = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3692,6 +3876,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3704,6 +3889,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3713,7 +3899,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3729,6 +3915,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3736,6 +3923,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3768,6 +3956,7 @@ mod tests { ]), secret_material_keys: vec!["private_key".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -3792,6 +3981,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3799,6 +3989,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3827,6 +4018,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(refresh_expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3835,6 +4027,7 @@ mod tests { let manual_expires_at_ms = refresh_expires_at_ms + 60_000; update_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3842,6 +4035,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -3860,6 +4054,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3869,7 +4064,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3887,6 +4082,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3894,6 +4090,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3909,6 +4106,7 @@ mod tests { .unwrap(); create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3916,6 +4114,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) @@ -3935,6 +4134,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -3958,6 +4158,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -3979,6 +4180,7 @@ mod tests { for name in ["first-graph", "second-graph"] { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3986,6 +4188,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), @@ -4005,6 +4208,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4028,6 +4232,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4046,6 +4251,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4064,6 +4270,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4071,6 +4278,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4102,6 +4310,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4118,6 +4327,7 @@ mod tests { material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4131,6 +4341,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4138,6 +4349,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4165,6 +4377,7 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4193,6 +4406,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -4202,6 +4416,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4213,6 +4428,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4236,6 +4452,7 @@ mod tests { &task_state, Request::new(DeleteProviderProfileRequest { id: "guarded-delete".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4262,7 +4479,7 @@ mod tests { let store = test_store().await; let created = provider_with_values("gitlab-local", "gitlab"); - let persisted = create_provider_record(&store, created.clone()) + let persisted = create_provider_record(&store, "default", created.clone()) .await .unwrap(); assert_eq!(persisted.object_name(), "gitlab-local"); @@ -4270,18 +4487,25 @@ mod tests { assert!(!persisted.object_id().is_empty()); let provider_id = persisted.object_id().to_string(); - let duplicate_err = create_provider_record(&store, created).await.unwrap_err(); + let duplicate_err = create_provider_record(&store, "default", created) + .await + .unwrap_err(); assert_eq!(duplicate_err.code(), Code::AlreadyExists); - let loaded = get_provider_record(&store, "gitlab-local").await.unwrap(); + let loaded = get_provider_record(&store, "default", "gitlab-local") + .await + .unwrap(); assert_eq!(loaded.object_id(), provider_id); - let listed = list_provider_records(&store, 100, 0).await.unwrap(); + let listed = list_provider_records(&store, "default", 100, 0) + .await + .unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].object_name(), "gitlab-local"); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4289,6 +4513,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4315,7 +4540,7 @@ mod tests { Some(&"REDACTED".to_string()), ); let stored: Provider = store - .get_message_by_name("gitlab-local") + .get_message_by_name("default", "gitlab-local") .await .unwrap() .unwrap(); @@ -4333,17 +4558,17 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); - let deleted_again = delete_provider_record(&store, "gitlab-local") + let deleted_again = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(!deleted_again); - let missing = get_provider_record(&store, "gitlab-local") + let missing = get_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(missing.code(), Code::NotFound); @@ -4355,6 +4580,7 @@ mod tests { let provider = create_provider_record( &store, + "default", Provider { credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), ..provider_with_values("gitlab-local", "gitlab") @@ -4364,6 +4590,7 @@ mod tests { .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "API_TOKEN", crate::provider_refresh::NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -4384,7 +4611,7 @@ mod tests { .await .unwrap(); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); @@ -4400,9 +4627,13 @@ mod tests { async fn delete_provider_rejects_attached_provider() { let store = test_store().await; - create_provider_record(&store, provider_with_values("gitlab-local", "gitlab")) - .await - .unwrap(); + create_provider_record( + &store, + "default", + provider_with_values("gitlab-local", "gitlab"), + ) + .await + .unwrap(); store .put_message(&Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -4411,6 +4642,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4421,7 +4653,7 @@ mod tests { .await .unwrap(); - let err = delete_provider_record(&store, "gitlab-local") + let err = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -4438,7 +4670,9 @@ mod tests { // Create provider and verify resource_version: 1 in response let created = provider_with_values("test-provider", "openai"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); assert_eq!( persisted.metadata.as_ref().unwrap().resource_version, 1, @@ -4448,6 +4682,7 @@ mod tests { // Update provider and verify resource_version: 2 in response let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4455,6 +4690,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4477,6 +4713,7 @@ mod tests { // Update again and verify resource_version: 3 let updated_again = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4484,6 +4721,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4511,6 +4749,7 @@ mod tests { let create_missing_type = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4518,6 +4757,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4531,6 +4771,7 @@ mod tests { let create_missing_credentials = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4538,6 +4779,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), @@ -4599,12 +4841,14 @@ mod tests { }), source: "delegated-refresh-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let delegated_refresh_bootstrap_provider = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4612,6 +4856,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), @@ -4635,12 +4880,14 @@ mod tests { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let mixed_required_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4648,6 +4895,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), @@ -4671,12 +4919,14 @@ mod tests { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let optional_static_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4684,6 +4934,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), @@ -4697,6 +4948,7 @@ mod tests { let vertex_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4704,6 +4956,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), @@ -4715,14 +4968,17 @@ mod tests { .unwrap(); assert!(vertex_empty.credentials.is_empty()); - let get_err = get_provider_record(store, "").await.unwrap_err(); + let get_err = get_provider_record(store, "default", "").await.unwrap_err(); assert_eq!(get_err.code(), Code::InvalidArgument); - let delete_err = delete_provider_record(store, "").await.unwrap_err(); + let delete_err = delete_provider_record(store, "default", "") + .await + .unwrap_err(); assert_eq!(delete_err.code(), Code::InvalidArgument); let update_missing_err = update_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4730,6 +4986,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4747,10 +5004,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("noop-test", "nvidia"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4758,6 +5018,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4782,7 +5043,7 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); let stored: Provider = store - .get_message_by_name("noop-test") + .get_message_by_name("default", "noop-test") .await .unwrap() .unwrap(); @@ -4794,10 +5055,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("delete-key-test", "openai"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4805,6 +5069,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), @@ -4828,7 +5093,7 @@ mod tests { ); assert!(!updated.config.contains_key("region")); let stored: Provider = store - .get_message_by_name("delete-key-test") + .get_message_by_name("default", "delete-key-test") .await .unwrap() .unwrap(); @@ -4845,10 +5110,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-preserve-test", "anthropic"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4856,6 +5124,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4874,10 +5143,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-change-test", "nvidia"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4885,6 +5157,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: HashMap::new(), @@ -4904,11 +5177,14 @@ mod tests { let store = test_store().await; let created = provider_with_values("validate-merge-test", "gitlab"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4916,6 +5192,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), @@ -4944,6 +5221,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), @@ -4954,6 +5232,7 @@ mod tests { let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4961,6 +5240,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) @@ -4978,7 +5258,9 @@ mod tests { #[tokio::test] async fn resolve_provider_env_empty_list_returns_empty() { let store = test_store().await; - let result = resolve_provider_environment(&store, &[]).await.unwrap(); + let result = resolve_provider_environment(&store, "default", &[]) + .await + .unwrap(); assert!(result.is_empty()); } @@ -4992,6 +5274,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: [ @@ -5007,9 +5290,11 @@ mod tests { .collect(), credential_expires_at_ms: HashMap::new(), }; - create_provider_record(&store, provider).await.unwrap(); + create_provider_record(&store, "default", provider) + .await + .unwrap(); - let result = resolve_provider_environment(&store, &["claude-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["claude-local".to_string()]) .await .unwrap(); assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); @@ -5022,14 +5307,16 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", provider_with_values("static-provider", "unprofiled-static-api"), ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["static-provider".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["static-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("API_TOKEN"), Some(&"token-123".to_string())); assert!(result.dynamic_credentials.is_empty()); @@ -5046,6 +5333,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5062,11 +5350,14 @@ mod tests { .into_iter() .collect(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["expiring-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["expiring-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("FRESH_TOKEN"), Some(&"fresh".to_string())); assert!(!result.contains_key("STALE_TOKEN")); assert_eq!( @@ -5078,7 +5369,7 @@ mod tests { #[tokio::test] async fn resolve_provider_env_unknown_name_returns_error() { let store = test_store().await; - let err = resolve_provider_environment(&store, &["nonexistent".to_string()]) + let err = resolve_provider_environment(&store, "default", &["nonexistent".to_string()]) .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -5095,6 +5386,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5107,11 +5399,14 @@ mod tests { config: HashMap::new(), credential_expires_at_ms: HashMap::new(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["test-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["test-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("VALID_KEY"), Some(&"value".to_string())); assert!(!result.contains_key("nested.api_key")); assert!(!result.contains_key("bad-key")); @@ -5122,6 +5417,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5129,6 +5425,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5144,6 +5441,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5151,6 +5449,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) @@ -5164,6 +5463,7 @@ mod tests { let result = resolve_provider_environment( &store, + "default", &["claude-local".to_string(), "gitlab-local".to_string()], ) .await @@ -5177,6 +5477,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5184,6 +5485,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) @@ -5196,6 +5498,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5203,6 +5506,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5219,6 +5523,7 @@ mod tests { let err = resolve_provider_environment( &store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -5234,6 +5539,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5241,6 +5547,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5263,7 +5570,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["vertex-local".to_string()]) .await .unwrap(); @@ -5308,6 +5615,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5315,6 +5623,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5336,9 +5645,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-bootstrap".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-bootstrap".to_string()]) + .await + .unwrap(); assert!(!result.contains_key("GOOGLE_SERVICE_ACCOUNT_KEY")); assert_eq!( @@ -5352,6 +5662,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5359,6 +5670,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5373,9 +5685,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-no-config".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-no-config".to_string()]) + .await + .unwrap(); // Static flags still present. assert!(!result.contains_key("CLAUDE_CODE_USE_VERTEX")); @@ -5400,6 +5713,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5407,6 +5721,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5431,9 +5746,10 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-collision".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-collision".to_string()]) + .await + .unwrap(); // Credential value wins over the injected static value. assert_eq!( @@ -5447,6 +5763,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5454,6 +5771,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -5465,7 +5783,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["openai-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["openai-local".to_string()]) .await .unwrap(); @@ -5485,6 +5803,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5492,6 +5811,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5507,6 +5827,7 @@ mod tests { .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5514,6 +5835,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5534,6 +5856,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5545,6 +5868,7 @@ mod tests { let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5552,6 +5876,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5579,6 +5904,7 @@ mod tests { create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5586,6 +5912,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5607,6 +5934,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5623,7 +5951,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5643,6 +5971,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5656,7 +5985,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5678,7 +6007,7 @@ mod tests { // Create a valid provider let provider = provider_with_values("test-validate-provider", "test-type"); - let created = create_provider_record(&store, provider.clone()) + let created = create_provider_record(&store, "default", provider.clone()) .await .unwrap(); @@ -5690,6 +6019,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), @@ -5703,7 +6033,7 @@ mod tests { "oversized-key-value".to_string(), ); - let result = update_provider_record(&store, update_req).await; + let result = update_provider_record(&store, "default", update_req).await; // Update should fail with InvalidArgument due to oversized key assert!(result.is_err(), "update with invalid data should fail"); @@ -5721,7 +6051,7 @@ mod tests { // Verify database still contains the ORIGINAL valid provider (not the invalid one) let stored = store - .get_message_by_name::("test-validate-provider") + .get_message_by_name::("default", "test-validate-provider") .await .unwrap() .expect("provider should still exist"); @@ -5753,11 +6083,17 @@ mod tests { // Spawn two concurrent creation attempts for the same provider let store1 = store.clone(); let provider1 = provider.clone(); - let handle1 = tokio::spawn(async move { create_provider_record(&store1, provider1).await }); + let handle1 = + tokio::spawn( + async move { create_provider_record(&store1, "default", provider1).await }, + ); let store2 = store.clone(); let provider2 = provider.clone(); - let handle2 = tokio::spawn(async move { create_provider_record(&store2, provider2).await }); + let handle2 = + tokio::spawn( + async move { create_provider_record(&store2, "default", provider2).await }, + ); // Wait for both to complete let result1 = handle1.await.unwrap(); @@ -5789,7 +6125,7 @@ mod tests { .find_map(Result::ok) .expect("should have one successful creation"); let retrieved = store - .get_message_by_name::("test-concurrent-provider") + .get_message_by_name::("default", "test-concurrent-provider") .await .unwrap(); assert!( @@ -5816,6 +6152,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5824,7 +6161,7 @@ mod tests { // Fetch the provider to get its current resource_version let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5843,6 +6180,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -5884,6 +6222,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5892,7 +6231,7 @@ mod tests { // Fetch the current state let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5911,6 +6250,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -5927,7 +6267,7 @@ mod tests { // Verify the provider was not modified let unchanged = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5951,6 +6291,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5959,7 +6300,7 @@ mod tests { // All three clients fetch the provider and see the same version let initial = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5981,6 +6322,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6013,7 +6355,7 @@ mod tests { // Final provider should have exactly 1 new credential key and resource_version = initial_version + 1 let final_provider = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6037,6 +6379,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), @@ -6139,6 +6482,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "github".to_string(), credentials: HashMap::new(), @@ -6152,4 +6496,238 @@ mod tests { "non-GCP provider should not inject any env vars" ); } + + #[tokio::test] + async fn provider_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateProviderRequest, CreateWorkspaceRequest, DeleteProviderRequest, + GetProviderRequest, ListProvidersRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Create same-named provider in each workspace via handlers. + let make_provider = || Provider { + metadata: None, + r#type: "custom".to_string(), + credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }; + + let created_default = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let default_id = created_default + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + let created_beta = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let beta_id = created_beta + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + assert_ne!(default_id, beta_id); + + // Get in each workspace returns the correct provider. + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), default_id); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // List is workspace-scoped. + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), default_id); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), beta_id); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_provider( + &state, + Request::new(DeleteProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.providers.is_empty()); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // all_workspaces returns providers from all workspaces. + // Re-create the "default" provider. + handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-d".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed52..5c01d15220 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -27,7 +27,7 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryComputeDriver, TelemetryOutcome, }; -use openshell_core::{ObjectId, ObjectName}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; use std::net::IpAddr; use std::pin::Pin; @@ -134,6 +134,9 @@ async fn handle_create_sandbox_inner( crate::grpc::validation::validate_label_value(value)?; } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -144,12 +147,13 @@ async fn handle_create_sandbox_inner( for name in &spec.providers { state .store - .get_message_by_name::(name) + .get_message_by_name::(&workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; } - validate_provider_environment_keys_unique(state.store.as_ref(), &spec.providers).await?; + validate_provider_environment_keys_unique(state.store.as_ref(), &workspace, &spec.providers) + .await?; // Ensure the template always carries the resolved image. let mut spec = spec; @@ -182,6 +186,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, + workspace, }), spec: Some(spec), status: None, @@ -236,14 +241,16 @@ pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - if name.is_empty() { + let req = request.into_inner(); + if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -258,21 +265,48 @@ pub(super) async fn handle_list_sandboxes( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let sandboxes: Vec = if request.label_selector.is_empty() { + let sandboxes: Vec = if request.all_workspaces { + if !request.label_selector.is_empty() { + return Err(Status::invalid_argument( + "label_selector is not supported with all_workspaces", + )); + } state .store - .list_messages(limit, request.offset) + .list_all_messages(limit, request.offset) .await .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? } else { - crate::grpc::validation::validate_label_selector(&request.label_selector)?; - state - .store - .list_messages_with_selector(&request.label_selector, limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes with selector failed: {e}")))? + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + if request.label_selector.is_empty() { + state + .store + .list_messages(&workspace, limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_messages_with_selector( + &workspace, + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| { + Status::internal(format!("list sandboxes with selector failed: {e}")) + })? + } }; Ok(Response::new(ListSandboxesResponse { sandboxes })) @@ -282,8 +316,11 @@ pub(super) async fn handle_list_sandbox_providers( state: &Arc, request: Request, ) -> Result, Status> { - let sandbox = sandbox_by_name(state, &request.into_inner().sandbox_name).await?; - let providers = providers_for_sandbox(state, &sandbox).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; + let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; Ok(Response::new(ListSandboxProvidersResponse { providers })) } @@ -292,6 +329,8 @@ pub(super) async fn handle_attach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -306,7 +345,7 @@ pub(super) async fn handle_attach_sandbox_provider( ))); } - get_provider_record(state.store.as_ref(), &request.provider_name) + get_provider_record(state.store.as_ref(), &workspace, &request.provider_name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -320,7 +359,7 @@ pub(super) async fn handle_attach_sandbox_provider( })?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -356,8 +395,12 @@ pub(super) async fn handle_attach_sandbox_provider( candidate_spec.providers.push(request.provider_name.clone()); } validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; - validate_provider_environment_keys_unique(state.store.as_ref(), &candidate_spec.providers) - .await?; + validate_provider_environment_keys_unique( + state.store.as_ref(), + &workspace, + &candidate_spec.providers, + ) + .await?; let provider_name = request.provider_name.clone(); let attached = Arc::new(AtomicBool::new(false)); @@ -406,6 +449,8 @@ pub(super) async fn handle_detach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -420,7 +465,7 @@ pub(super) async fn handle_detach_sandbox_provider( } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -497,19 +542,22 @@ async fn handle_delete_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; + let req = request.into_inner(); + let name = req.name; if name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox_id = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &name) .await .ok() .flatten() .map(|sandbox| sandbox.object_id().to_string()); - let deleted = state.compute.delete_sandbox(&name).await?; + let deleted = state.compute.delete_sandbox(&workspace, &name).await?; if deleted && let Some(sandbox_id) = sandbox_id { state.telemetry.end_sandbox_session(&sandbox_id); } @@ -517,14 +565,18 @@ async fn handle_delete_sandbox_inner( Ok(Response::new(DeleteSandboxResponse { deleted })) } -async fn sandbox_by_name(state: &Arc, name: &str) -> Result { +async fn sandbox_by_name( + state: &Arc, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("sandbox_name is required")); } state .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found")) @@ -533,6 +585,7 @@ async fn sandbox_by_name(state: &Arc, name: &str) -> Result, sandbox: &Sandbox, + workspace: &str, ) -> Result, Status> { let provider_names = sandbox .spec @@ -542,7 +595,7 @@ async fn providers_for_sandbox( let mut providers = Vec::with_capacity(provider_names.len()); for name in provider_names { - let provider = get_provider_record(state.store.as_ref(), name) + let provider = get_provider_record(state.store.as_ref(), workspace, name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -1361,6 +1414,7 @@ pub(super) async fn handle_create_ssh_session( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: sandbox.object_workspace().to_string(), }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -1378,6 +1432,7 @@ pub(super) async fn handle_create_ssh_session( SshSession::object_type(), &token, session.object_name(), + "", &session.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1436,6 +1491,7 @@ pub(super) async fn handle_revoke_ssh_session( SshSession::object_type(), session.object_id(), session.object_name(), + "", &session.encode_to_vec(), None, WriteCondition::MatchResourceVersion(resource_version), @@ -2253,6 +2309,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) @@ -2270,6 +2327,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2304,6 +2362,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2313,7 +2372,7 @@ mod tests { assert!(response.attached); let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -2347,6 +2406,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2356,7 +2416,7 @@ mod tests { assert!(!response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2388,6 +2448,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2397,7 +2458,7 @@ mod tests { assert!(response.detached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2412,6 +2473,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2438,6 +2500,7 @@ mod tests { &state, Request::new(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), + workspace: String::new(), }), ) .await @@ -2467,6 +2530,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2640,6 +2704,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2672,6 +2737,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2703,6 +2769,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2752,6 +2819,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2798,6 +2866,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2807,7 +2876,7 @@ mod tests { assert!(response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2852,6 +2921,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2863,7 +2933,7 @@ mod tests { // Verify sandbox was not modified let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2898,6 +2968,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2924,6 +2995,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3075,7 +3147,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3088,6 +3160,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3099,7 +3172,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3126,7 +3199,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3139,6 +3212,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3156,7 +3230,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3188,7 +3262,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3201,6 +3275,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3212,7 +3287,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3239,7 +3314,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3252,6 +3327,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3269,7 +3345,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3312,7 +3388,7 @@ mod tests { // All three clients fetch the sandbox and see version 1 let initial_version = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -3332,6 +3408,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, + workspace: String::new(), }), ) .await @@ -3368,7 +3445,7 @@ mod tests { // Final sandbox should have exactly 1 provider and resource_version = initial_version + 1 let final_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3378,4 +3455,176 @@ mod tests { initial_version + 1 ); } + + #[tokio::test] + async fn sandbox_crud_is_workspace_isolated() { + use crate::persistence::ObjectType; + use openshell_core::proto::{ + CreateWorkspaceRequest, GetSandboxRequest, ListSandboxesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "shared-name" in each workspace. + let mut sbx_default = test_sandbox("shared-name", Vec::new()); + sbx_default.metadata.as_mut().unwrap().id = "sbx-default-id".to_string(); + sbx_default.metadata.as_mut().unwrap().workspace = "default".to_string(); + state.store.put_message(&sbx_default).await.unwrap(); + + let mut sbx_beta = test_sandbox("shared-name", Vec::new()); + sbx_beta.metadata.as_mut().unwrap().id = "sbx-beta-id".to_string(); + sbx_beta.metadata.as_mut().unwrap().workspace = "beta".to_string(); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Get in "default" returns the default sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-default-id"); + + // Get in "beta" returns the beta sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // List in "default" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-default-id",); + + // List in "beta" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-beta-id"); + + // Delete in "default" (via store) does not affect "beta". + state + .store + .delete_by_name(Sandbox::object_type(), "default", "shared-name") + .await + .unwrap(); + + // "default" now has 0 sandboxes. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.sandboxes.is_empty()); + + // "beta" still has its sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // all_workspaces returns sandboxes from all workspaces. + // Re-create the "default" sandbox so both workspaces have one. + state + .store + .put( + Sandbox::object_type(), + "sbx-default-2", + "sandbox-d", + "default", + &Sandbox::default().encode_to_vec(), + None, + ) + .await + .unwrap(); + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 246d639bef..31e3389b4b 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -4,12 +4,12 @@ use std::collections::HashMap; use std::sync::Arc; -use openshell_core::ObjectId; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest, ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use prost::Message as _; use tonic::{Request, Response, Status}; use uuid::Uuid; @@ -26,6 +26,8 @@ pub(super) async fn handle_expose_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { @@ -34,7 +36,7 @@ pub(super) async fn handle_expose_service( let sandbox = state .store - .get_message_by_name::(&req.sandbox) + .get_message_by_name::(&workspace, &req.sandbox) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -45,7 +47,7 @@ pub(super) async fn handle_expose_service( // Fetch existing endpoint to determine create vs. update path let existing = state .store - .get_message_by_name::(&key) + .get_message_by_name::(&workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}")))?; @@ -87,6 +89,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, + workspace: workspace.clone(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -102,6 +105,7 @@ pub(super) async fn handle_expose_service( ServiceEndpoint::object_type(), &id, &key, + &workspace, &endpoint.encode_to_vec(), Some(&labels_json), condition, @@ -114,7 +118,7 @@ pub(super) async fn handle_expose_service( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, &req.sandbox, &req.service) + let url = service_routing::endpoint_url(&state.config, &workspace, &req.sandbox, &req.service) .unwrap_or_default(); service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); @@ -129,10 +133,12 @@ pub(super) async fn handle_get_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service) + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service) .await? .ok_or_else(|| Status::not_found("service endpoint not found"))?; @@ -144,18 +150,42 @@ pub(super) async fn handle_list_services( request: Request, ) -> Result, Status> { let req = request.into_inner(); + if req.all_workspaces && !req.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } if !req.sandbox.is_empty() { validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; } let limit = super::clamp_limit(req.limit, 100, super::MAX_PAGE_SIZE); - let endpoints: Vec = if req.sandbox.is_empty() { - state.store.list_messages(limit, req.offset).await + let endpoints: Vec = if req.all_workspaces { + if !req.sandbox.is_empty() { + return Err(Status::invalid_argument( + "sandbox filter is not supported with all_workspaces", + )); + } + state.store.list_all_messages(limit, req.offset).await } else { - state - .store - .list_messages_with_selector(&format!("sandbox={}", req.sandbox), limit, req.offset) - .await + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + if req.sandbox.is_empty() { + state + .store + .list_messages(&workspace, limit, req.offset) + .await + } else { + state + .store + .list_messages_with_selector( + &workspace, + &format!("sandbox={}", req.sandbox), + limit, + req.offset, + ) + .await + } } .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; @@ -172,10 +202,12 @@ pub(super) async fn handle_delete_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service).await?; + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service).await?; let Some(endpoint) = endpoint else { return Ok(Response::new(DeleteServiceResponse { deleted: false })); }; @@ -183,7 +215,7 @@ pub(super) async fn handle_delete_service( let key = service_routing::endpoint_key(&req.sandbox, &req.service); let deleted = state .store - .delete_by_name(ServiceEndpoint::object_type(), &key) + .delete_by_name(ServiceEndpoint::object_type(), &workspace, &key) .await .map_err(|e| Status::internal(format!("delete endpoint failed: {e}")))?; @@ -196,13 +228,14 @@ pub(super) async fn handle_delete_service( async fn get_service_endpoint( state: &Arc, + workspace: &str, sandbox: &str, service: &str, ) -> Result, Status> { let key = service_routing::endpoint_key(sandbox, service); state .store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}"))) } @@ -211,8 +244,10 @@ fn service_endpoint_response( state: &Arc, endpoint: ServiceEndpoint, ) -> ServiceEndpointResponse { + let workspace = endpoint.object_workspace(); let url = service_routing::endpoint_url( &state.config, + workspace, &endpoint.sandbox_name, &endpoint.service_name, ) @@ -286,6 +321,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() @@ -331,6 +367,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -344,6 +381,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -360,6 +399,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -372,6 +412,7 @@ mod tests { Request::new(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -384,6 +425,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -396,6 +438,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -419,6 +463,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -433,6 +478,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -457,6 +503,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -478,6 +526,7 @@ mod tests { service: "web".to_string(), target_port: 7070, domain: true, + workspace: "default".to_string(), }), ) .await @@ -493,6 +542,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -507,6 +557,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -529,6 +580,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -541,4 +593,223 @@ mod tests { ); assert_ne!(port, 7070, "port should not be the original value"); } + + #[tokio::test] + async fn service_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateWorkspaceRequest, DeleteServiceRequest, GetServiceRequest, ListServicesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "my-sandbox" in each workspace. + seed_sandbox(&state, "my-sandbox").await; + + let mut sbx_beta = Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-my-sandbox-beta".to_string(), + name: "my-sandbox".to_string(), + created_at_ms: 1_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + spec: Some(openshell_core::proto::SandboxSpec::default()), + ..Default::default() + }; + sbx_beta.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Expose same service name on the same sandbox name in each workspace. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 8080, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 9090, + domain: true, + workspace: "beta".to_string(), + }), + ) + .await + .unwrap(); + + // Get in "default" returns port 8080. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 8080); + + // Get in "beta" returns port 9090. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // List in each workspace returns 1 service. + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 8080 + ); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 9090 + ); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_service( + &state, + Request::new(DeleteServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.services.is_empty()); + + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // all_workspaces returns services from all workspaces. + // Re-create the "default" service. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "api".to_string(), + target_port: 3000, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 0b3548b062..2d57f57a47 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1139,6 +1139,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: provider_type.to_string(), credentials, diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs new file mode 100644 index 0000000000..f484fdfd11 --- /dev/null +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -0,0 +1,676 @@ +// 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, + Workspace, WorkspaceMember, WorkspaceRole, +}; +use prost::Message; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::persistence::{ObjectType, 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")); + } + if name.len() > 63 { + return Err(Status::invalid_argument( + "workspace name must be 63 characters or fewer", + )); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument( + "workspace name must contain only lowercase alphanumeric characters or hyphens", + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument( + "workspace name must not start or end with a hyphen", + )); + } + Ok(()) +} + +/// 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. +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", + )); + } + + let mut blocking = Vec::new(); + for (object_type, label) in [ + (Sandbox::object_type(), "sandbox"), + (Provider::object_type(), "provider"), + (ServiceEndpoint::object_type(), "service"), + (InferenceRoute::object_type(), "inference route"), + ] { + 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. + let members: Vec = state + .store + .list_messages(&name, MAX_WORKSPACE_MEMBERS, 0) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + for member in &members { + if let Some(meta) = &member.metadata { + state + .store + .delete(WorkspaceMember::object_type(), &meta.id) + .await + .map_err(|e| { + Status::internal(format!("delete workspace member '{}' failed: {e}", meta.name)) + })?; + } + } + + 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 existing: Vec = state + .store + .list_messages(&workspace, MAX_WORKSPACE_MEMBERS, 0) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + if existing.len() as u32 >= 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_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); + } +} diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 43416c35d1..7d47c1f094 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -3,14 +3,14 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> -use openshell_core::ObjectId; +use openshell_core::{ObjectId, ObjectWorkspace}; 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_providers::normalize_provider_type; @@ -68,26 +68,43 @@ 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(); + let workspace = if workspace.is_empty() { "default" } else { 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 +119,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 +127,31 @@ 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 +166,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 +194,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 +210,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 +228,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,6 +235,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -221,15 +246,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, @@ -240,12 +264,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, @@ -875,9 +899,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", )), @@ -887,13 +911,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); } @@ -932,10 +959,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}")))?; @@ -960,12 +988,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 )) })?; @@ -1030,8 +1058,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + 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, @@ -1048,6 +1077,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), @@ -1093,8 +1123,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", @@ -1105,8 +1136,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", @@ -1145,6 +1177,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1167,8 +1200,9 @@ mod tests { .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", @@ -1187,7 +1221,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"); @@ -1221,6 +1255,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1237,8 +1272,9 @@ mod tests { .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", @@ -1269,6 +1305,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1296,8 +1333,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, @@ -1323,7 +1361,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()); @@ -1342,7 +1380,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"); @@ -1380,7 +1418,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"); @@ -1422,7 +1460,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"); @@ -1447,7 +1485,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()); @@ -1470,10 +1508,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"); @@ -1494,6 +1532,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1517,8 +1556,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + 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, @@ -1530,7 +1570,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"); @@ -1566,7 +1606,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"); @@ -1585,7 +1625,7 @@ mod tests { .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"); @@ -1599,8 +1639,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", @@ -1617,7 +1658,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. @@ -1628,6 +1669,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1651,8 +1693,9 @@ mod tests { .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", @@ -1669,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"); @@ -1723,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"); @@ -1743,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"); @@ -1759,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", @@ -1771,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"); @@ -1816,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", @@ -1856,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", @@ -1876,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(); @@ -1899,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", @@ -1965,6 +2012,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -2869,8 +2917,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", @@ -2882,8 +2931,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", @@ -2940,7 +2990,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"); @@ -2956,8 +3006,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", @@ -2970,8 +3021,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", @@ -2983,8 +3035,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", @@ -3007,7 +3060,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"); @@ -3027,4 +3080,130 @@ 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(), + }; + 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(), + }; + 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 6462ccbbf3..86abac897d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -369,6 +369,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"); } @@ -895,6 +897,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::{ @@ -1029,7 +1075,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 { @@ -1160,7 +1206,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 9e70c64721..01c0b6e7a7 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -1678,8 +1678,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..7f87280a11 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. @@ -221,6 +222,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 +231,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 +266,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 +289,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,19 +304,35 @@ 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)) + /// 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, limit, offset)) + 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_by_type(object_type, limit, offset)) } /// List objects by type and application-owned scope. @@ -323,16 +346,23 @@ 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, label_selector, limit, offset)) + store_dispatch!(self.list_with_selector( + object_type, + workspace, + label_selector, + limit, + offset + )) } // ----------------------------------------------------------------------- @@ -341,12 +371,17 @@ 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<()> { + debug_assert!( + !T::requires_workspace() || !message.object_workspace().is_empty(), + "{} 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 +395,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 +414,41 @@ 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(), 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(T::object_type(), limit, offset) + self.list_by_type(T::object_type(), limit, offset) .await? .into_iter() .map(decode_record) @@ -409,11 +461,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 +503,7 @@ impl Store { + ObjectId + ObjectName + ObjectLabels + + ObjectWorkspace + SetResourceVersion + GetResourceVersion + Clone, @@ -491,12 +545,19 @@ impl Store { })?) }; + debug_assert!( + !T::requires_workspace() || !updated.object_workspace().is_empty(), + "{} 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 +592,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 +657,24 @@ 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<()> { + debug_assert!( + !T::requires_workspace() || !message.object_workspace().is_empty(), + "{} 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 +687,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 529bc38bee..00e35c71cf 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -59,6 +59,7 @@ impl PostgresStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -70,9 +71,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 @@ -81,6 +82,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) @@ -90,11 +92,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, @@ -110,14 +114,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) @@ -177,9 +182,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, @@ -190,6 +195,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) @@ -241,11 +247,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>, @@ -258,9 +266,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, @@ -271,6 +279,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) @@ -288,7 +297,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 ", @@ -305,16 +314,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 @@ -333,25 +344,60 @@ 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 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 @@ -377,7 +423,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 @@ -398,6 +444,7 @@ LIMIT $3 OFFSET $4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -410,14 +457,15 @@ 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 +WHERE object_type = $1 AND workspace = $2 AND labels @> $3 ORDER BY created_at_ms ASC, name ASC -LIMIT $3 OFFSET $4 +LIMIT $4 OFFSET $5 ", ) .bind(object_type) + .bind(workspace) .bind(&labels_jsonb) .bind(i64::from(limit)) .bind(i64::from(offset)) @@ -432,6 +480,7 @@ LIMIT $3 OFFSET $4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -453,9 +502,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) @@ -465,6 +514,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))?; @@ -630,6 +680,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 @@ -638,9 +689,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 @@ -656,6 +707,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))?; @@ -847,6 +899,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 1958b32323..9b393307d7 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -85,6 +85,7 @@ impl SqliteStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -92,9 +93,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" @@ -103,6 +104,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("{}")) @@ -112,11 +114,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, @@ -128,13 +132,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("{}")) @@ -195,9 +200,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", @@ -207,6 +212,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("{}")) @@ -215,9 +221,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, @@ -261,11 +270,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>, @@ -274,9 +285,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", @@ -287,6 +298,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) @@ -304,7 +316,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 "#, @@ -321,16 +333,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 @@ -354,14 +368,20 @@ 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 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 @@ -370,6 +390,33 @@ 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", "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, @@ -377,7 +424,7 @@ WHERE "object_type" = ?1 AND "name" = ?2 ) -> 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 ORDER BY "created_at_ms" ASC, "name" ASC @@ -403,7 +450,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 @@ -423,6 +470,7 @@ LIMIT ?3 OFFSET ?4 pub async fn list_with_selector( &self, object_type: &str, + workspace: &str, label_selector: &str, limit: u32, offset: u32, @@ -430,7 +478,7 @@ 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() @@ -453,6 +501,7 @@ LIMIT ?3 OFFSET ?4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -474,9 +523,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) @@ -486,6 +535,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))?; @@ -651,6 +701,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 @@ -660,9 +711,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" @@ -678,6 +729,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))?; @@ -900,6 +952,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 d092b68de0..de8e138d98 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -11,7 +11,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(); @@ -26,7 +26,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()); } @@ -212,14 +212,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(); @@ -237,12 +237,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"); @@ -253,7 +253,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(); @@ -292,12 +292,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(); @@ -305,7 +305,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()); } @@ -322,7 +325,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(); @@ -331,7 +334,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()); @@ -342,14 +345,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(); @@ -361,18 +370,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(); @@ -381,7 +404,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(); } @@ -391,14 +421,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()); @@ -444,6 +474,7 @@ async fn labels_round_trip() { "sandbox", "id-1", "labeled-sandbox", + "default", b"payload", Some(labels), ) @@ -459,11 +490,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 @@ -471,6 +516,7 @@ async fn label_selector_single_match() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -478,7 +524,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(); @@ -497,6 +543,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-1", "s1", + "default", b"p1", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -507,6 +554,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-2", "s2", + "default", b"p2", Some(r#"{"env":"prod","team":"data"}"#), ) @@ -517,6 +565,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"dev","team":"platform"}"#), ) @@ -524,7 +573,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(); @@ -537,12 +586,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(); @@ -557,25 +613,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); @@ -586,16 +649,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(); @@ -613,7 +683,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(); @@ -630,7 +700,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(); @@ -650,11 +720,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(); @@ -684,7 +754,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(); @@ -715,7 +785,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(); @@ -739,15 +809,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(); @@ -792,15 +862,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(); @@ -828,11 +898,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(); @@ -930,6 +1000,7 @@ async fn cas_put_if_must_create_succeeds() { "sandbox", "id-1", "new-sandbox", + "default", b"payload", None, WriteCondition::MustCreate, @@ -956,6 +1027,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-1", + "default", b"payload1", None, WriteCondition::MustCreate, @@ -969,6 +1041,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-2", + "default", b"payload2", None, WriteCondition::MustCreate, @@ -993,6 +1066,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1006,6 +1080,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1032,6 +1107,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1045,6 +1121,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(99), @@ -1075,6 +1152,7 @@ async fn cas_delete_if_succeeds_with_correct_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1100,6 +1178,7 @@ async fn cas_delete_if_fails_with_wrong_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1132,6 +1211,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1146,6 +1226,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1160,6 +1241,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v3", None, WriteCondition::MatchResourceVersion(2), @@ -1185,6 +1267,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"initial", None, WriteCondition::MustCreate, @@ -1202,6 +1285,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", format!("update-{i}").as_bytes(), None, WriteCondition::MatchResourceVersion(1), @@ -1243,6 +1327,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: None, status: None, @@ -1282,6 +1367,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + 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 9a6333543d..183fc913f7 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -13,6 +13,7 @@ pub trait PolicyStoreExt { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -72,6 +73,7 @@ pub trait PolicyStoreExt { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult; async fn get_draft_chunk(&self, id: &str) -> PersistenceResult>; @@ -110,6 +112,7 @@ impl PolicyStoreExt for Store { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -117,12 +120,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 } } @@ -213,10 +216,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/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c4..da432bc440 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -6,6 +6,7 @@ #![allow(clippy::result_large_err)] use crate::persistence::{ObjectType, Store, current_time_ms}; +use openshell_core::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, StoredProviderCredentialRefreshState, @@ -79,7 +80,7 @@ pub async fn list_all_refresh_states( let mut offset = 0; loop { let records = store - .list( + .list_by_type( StoredProviderCredentialRefreshState::object_type(), REFRESH_WORKER_PAGE_SIZE, offset, @@ -108,24 +109,30 @@ pub async fn list_all_refresh_states( pub async fn get_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result, Status> { let name = refresh_state_name(provider_id, credential_key); store - .get_message_by_name::(&name) + .get_message_by_name::(workspace, &name) .await .map_err(|e| Status::internal(format!("fetch provider refresh state failed: {e}"))) } pub async fn delete_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result { let name = refresh_state_name(provider_id, credential_key); store - .delete_by_name(StoredProviderCredentialRefreshState::object_type(), &name) + .delete_by_name( + StoredProviderCredentialRefreshState::object_type(), + workspace, + &name, + ) .await .map_err(|e| Status::internal(format!("delete provider refresh state failed: {e}"))) } @@ -136,10 +143,11 @@ pub async fn delete_refresh_states_for_provider( ) -> Result { let states = list_refresh_states_for_provider(store, provider_id).await?; let mut deleted = 0; - for state in states { + for state in &states { if store .delete_by_name( StoredProviderCredentialRefreshState::object_type(), + state.object_workspace(), state.object_name(), ) .await @@ -181,6 +189,7 @@ pub struct NewRefreshStateConfig { #[allow(clippy::unnecessary_wraps)] pub fn new_refresh_state( provider: &Provider, + workspace: &str, credential_key: &str, config: NewRefreshStateConfig, ) -> Result { @@ -200,6 +209,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), provider_id, provider_name, @@ -294,15 +304,17 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) pub async fn refresh_provider_credential( store: &Store, + workspace: &str, provider_name: &str, credential_key: &str, ) -> Result { 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::not_found("provider not found"))?; - let Some(mut state) = get_refresh_state(store, provider.object_id(), credential_key).await? + let Some(mut state) = + get_refresh_state(store, workspace, provider.object_id(), credential_key).await? else { return Err(Status::not_found("provider refresh state not found")); }; @@ -321,7 +333,7 @@ pub async fn refresh_provider_credential( Ok(minted) => { let now_ms = current_time_ms(); if let Err(err) = - apply_minted_credential(store, &provider, credential_key, &minted).await + apply_minted_credential(store, workspace, &provider, credential_key, &minted).await { state.status = "error".to_string(); state.last_error = err.message().to_string(); @@ -399,6 +411,7 @@ pub async fn refresh_provider_credential( async fn apply_minted_credential( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, minted: &MintedCredential, @@ -414,8 +427,10 @@ async fn apply_minted_credential( } else { updated.credential_expires_at_ms.remove(credential_key); } - crate::grpc::provider::validate_provider_update_against_attached_sandboxes(store, &updated) - .await?; + crate::grpc::provider::validate_provider_update_against_attached_sandboxes( + store, workspace, &updated, + ) + .await?; store .update_message_cas::(provider.object_id(), 0, |current| { current @@ -752,8 +767,13 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { status = %state.status, "refreshing provider credential" ); - if let Err(err) = - refresh_provider_credential(store, &state.provider_name, &state.credential_key).await + if let Err(err) = refresh_provider_credential( + store, + state.object_workspace(), + &state.provider_name, + &state.credential_key, + ) + .await { warn!( provider = %state.provider_name, @@ -852,6 +872,7 @@ mod tests { let before_refresh_ms = crate::persistence::current_time_ms(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -870,9 +891,10 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = refresh_provider_credential(&store, "my-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = + refresh_provider_credential(&store, "default", "my-graph", "MS_GRAPH_ACCESS_TOKEN") + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); assert!(refreshed.next_refresh_at_ms > 0); @@ -880,7 +902,7 @@ mod tests { assert!(refreshed.last_error.is_empty()); let stored = store - .get_message_by_name::("my-graph") + .get_message_by_name::("default", "my-graph") .await .unwrap() .unwrap(); @@ -924,6 +946,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -935,6 +958,7 @@ mod tests { .unwrap(); let state = new_refresh_state( &provider_b, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -953,21 +977,30 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let err = refresh_provider_credential(&store, "refreshing-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap_err(); + let err = refresh_provider_credential( + &store, + "default", + "refreshing-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN")); - let stored_state = - get_refresh_state(&store, provider_b.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider_b.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!(stored_state.status, "error"); assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN")); let stored_provider = store - .get_message_by_name::("refreshing-graph") + .get_message_by_name::("default", "refreshing-graph") .await .unwrap() .unwrap(); @@ -1003,6 +1036,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, @@ -1021,15 +1055,19 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = - refresh_provider_credential(&store, "my-delegated-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + "my-delegated-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored_provider = store - .get_message_by_name::("my-delegated-graph") + .get_message_by_name::("default", "my-delegated-graph") .await .unwrap() .unwrap(); @@ -1044,10 +1082,15 @@ mod tests { Some(&refreshed.expires_at_ms) ); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!( stored_state.material.get("refresh_token"), Some(&"rotated-refresh-token".to_string()) @@ -1082,6 +1125,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "GOOGLE_DRIVE_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt, @@ -1104,14 +1148,14 @@ mod tests { put_refresh_state(&store, &state).await.unwrap(); let refreshed = - refresh_provider_credential(&store, "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") + refresh_provider_credential(&store, "default", "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") .await .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored = store - .get_message_by_name::("my-drive") + .get_message_by_name::("default", "my-drive") .await .unwrap() .unwrap(); @@ -1128,6 +1172,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -1145,15 +1190,20 @@ mod tests { run_refresh_worker_tick(&store).await.unwrap(); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_ne!(stored_state.status, "error"); assert!(stored_state.last_error.is_empty()); let stored_provider = store - .get_message_by_name::("my-external") + .get_message_by_name::("default", "my-external") .await .unwrap() .unwrap(); @@ -1172,6 +1222,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba93..82db74bdab 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,52 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { } } -fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { +fn endpoint_host( + config: &ServiceRoutingConfig, + workspace: &str, + sandbox: &str, + service: &str, +) -> Option { let base_domain = config.base_domains.first()?; + let ws = if workspace.is_empty() { "default" } else { workspace }; Some(if service.is_empty() { - format!("{sandbox}.{base_domain}") + format!("{ws}--{sandbox}.{base_domain}") } else { - format!("{sandbox}--{service}.{base_domain}") + format!("{ws}--{sandbox}--{service}.{base_domain}") }) } -pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { +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("--") { - if service.is_empty() || service.contains("--") { - return None; + let parts: Vec<&str> = encoded.splitn(3, "--").collect(); + return match parts.len() { + 2 => { + if parts[0].is_empty() || parts[1].is_empty() { + return None; + } + Some(( + parts[0].to_string(), + parts[1].to_string(), + String::new(), + )) } - (sandbox, service) - } else { - (encoded, "") + 3 => { + if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { + return None; + } + Some(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )) + } + _ => None, }; - if sandbox.is_empty() || sandbox.contains("--") { - return None; - } - return Some((sandbox.to_string(), service.to_string())); } None } @@ -116,11 +135,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 +230,11 @@ 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 +431,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 +595,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,6 +829,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), @@ -839,8 +865,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/") ); } @@ -851,8 +877,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/") ); } @@ -863,8 +889,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/") ); } @@ -876,24 +902,44 @@ 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() + )) ); } @@ -908,16 +954,24 @@ 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() + )) ); } @@ -929,6 +983,21 @@ 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 identifies_sandbox_service_request_from_host_header() { let request = Request::builder() @@ -1100,4 +1169,32 @@ 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/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 752fee1c08..8c168eccfe 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -37,7 +37,7 @@ async fn reap_expired_sessions(store: &Store) -> Result<(), String> { let now_ms = now_ms(); let records = store - .list(SshSession::object_type(), 1000, 0) + .list_by_type(SshSession::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -86,6 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 4adf9e8b6f..03fd032d3a 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,6 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), ..Default::default() } diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 3934c8af42..007917f2c0 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -473,6 +473,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index bd94d151e5..c55ebdd6c2 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -413,6 +413,51 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn create_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn list_workspaces( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn delete_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn add_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn remove_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_workspace_members( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..5e16c83636 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -529,12 +529,19 @@ pub struct App { pub gateway_selected: usize, pub pending_gateway_switch: Option, + // Workspace filter + pub current_workspace: String, + pub all_workspaces: bool, + pub workspace_names: Vec, + pub pending_workspace_refresh: bool, + // Provider list pub providers_v2_enabled: bool, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, pub provider_cred_keys: Vec, + pub provider_workspaces: Vec, pub provider_selected: usize, pub provider_count: usize, @@ -575,6 +582,7 @@ pub struct App { pub sandbox_notes: Vec, /// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string). pub sandbox_labels: Vec, + pub sandbox_workspaces: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -880,11 +888,16 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + current_workspace: "default".to_string(), + all_workspaces: false, + workspace_names: Vec::new(), + pending_workspace_refresh: false, providers_v2_enabled: false, provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), provider_cred_keys: Vec::new(), + provider_workspaces: Vec::new(), provider_selected: 0, provider_count: 0, create_provider_form: None, @@ -903,6 +916,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), + sandbox_workspaces: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -1053,6 +1067,37 @@ impl App { } } + pub fn cycle_workspace(&mut self) { + if self.all_workspaces { + self.all_workspaces = false; + self.current_workspace = "default".to_string(); + } else if self.workspace_names.is_empty() { + self.all_workspaces = true; + } else { + let current_idx = self + .workspace_names + .iter() + .position(|n| n == &self.current_workspace); + match current_idx { + Some(idx) if idx + 1 < self.workspace_names.len() => { + self.current_workspace = self.workspace_names[idx + 1].clone(); + } + _ => { + self.all_workspaces = true; + } + } + } + self.pending_workspace_refresh = true; + } + + pub fn workspace_display(&self) -> &str { + if self.all_workspaces { + "all" + } else { + &self.current_workspace + } + } + pub fn handle_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.running = false; @@ -1359,6 +1404,9 @@ impl App { self.input_mode = InputMode::Command; self.command_input.clear(); } + KeyCode::Char('w') => { + self.cycle_workspace(); + } KeyCode::Char('j') | KeyCode::Down if self.sandbox_count > 0 => { self.sandbox_selected = (self.sandbox_selected + 1).min(self.sandbox_count - 1); } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..8b4b8c7280 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -20,7 +20,7 @@ use crossterm::terminal::{ use miette::{IntoDiagnostic, Result}; use openshell_bootstrap::list_gateways_with_source; use openshell_core::auth::EdgeAuthInterceptor; -use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName}; +use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName, ObjectWorkspace}; use openshell_core::proto::SandboxPhase; use openshell_core::proto::open_shell_client::OpenShellClient; use ratatui::Terminal; @@ -159,6 +159,11 @@ pub async fn run( let snapshot = std::mem::take(&mut app.approve_all_confirm_chunks); spawn_draft_approve_all(&app, snapshot, events.sender()); } + if app.pending_workspace_refresh { + app.pending_workspace_refresh = false; + refresh_providers(&mut app).await; + refresh_sandboxes(&mut app).await; + } } Some(Event::LogLines(lines)) => { app.sandbox_log_lines.extend(lines); @@ -623,6 +628,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { }; let mut client = app.client.clone(); + let workspace = app.current_workspace.clone(); let handle = tokio::spawn(async move { // Phase 1: Fetch initial history via unary RPC. @@ -632,6 +638,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { since_ms: 0, sources: vec![], min_level: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.get_sandbox_logs(req)).await { @@ -728,7 +735,10 @@ async fn handle_sandbox_delete(app: &mut App) { } } - let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name }; + let req = openshell_core::proto::DeleteSandboxRequest { + name: sandbox_name, + workspace: app.current_workspace.clone(), + }; match app.client.delete_sandbox(req).await { Ok(_) => { app.cancel_log_stream(); @@ -760,6 +770,7 @@ async fn fetch_sandbox_detail(app: &mut App) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.current_workspace.clone(), }; // Step 1: Fetch sandbox metadata (providers, sandbox ID). @@ -845,6 +856,7 @@ async fn handle_shell_connect( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.current_workspace.clone(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -992,6 +1004,7 @@ async fn handle_exec_command( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.to_string(), + workspace: app.current_workspace.clone(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1328,6 +1341,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let endpoint = app.endpoint.clone(); let gateway_name = app.gateway_name.clone(); let need_ready = !ports.is_empty() || !app.pending_exec_command.is_empty(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let has_custom_image = !image.is_empty(); @@ -1360,6 +1374,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + workspace: workspace.clone(), }; let sandbox_name = @@ -1400,6 +1415,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: workspace.clone(), }; // Retry on transient errors. if let Ok(resp) = client.get_sandbox(req).await @@ -1597,6 +1613,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { form.name.clone() }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { // Try with the chosen name, retry with suffix on collision. @@ -1615,12 +1632,14 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.clone(), }), r#type: ptype.clone(), credentials: credentials.clone(), config: HashMap::default(), credential_expires_at_ms: HashMap::default(), }), + workspace: workspace.clone(), }; match client.create_provider(req).await { @@ -1658,9 +1677,13 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.current_workspace.clone(); tokio::spawn(async move { - let req = openshell_core::proto::GetProviderRequest { name }; + let req = openshell_core::proto::GetProviderRequest { + name, + workspace, + }; match tokio::time::timeout(Duration::from_secs(5), client.get_provider(req)).await { Ok(Ok(resp)) => { if let Some(provider) = resp.into_inner().provider { @@ -1694,6 +1717,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1707,6 +1731,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: workspace.clone(), }), r#type: ptype, credentials, @@ -1714,6 +1739,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { credential_expires_at_ms: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -1739,9 +1765,13 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.current_workspace.clone(); tokio::spawn(async move { - let req = openshell_core::proto::DeleteProviderRequest { name }; + let req = openshell_core::proto::DeleteProviderRequest { + name, + workspace, + }; match tokio::time::timeout(Duration::from_secs(5), client.delete_provider(req)).await { Ok(Ok(resp)) => { let _ = tx.send(Event::ProviderDeleteResult(Ok(resp.into_inner().deleted))); @@ -1778,9 +1808,14 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { - let req = openshell_core::proto::ApproveDraftChunkRequest { name, chunk_id }; + let req = openshell_core::proto::ApproveDraftChunkRequest { + name, + chunk_id, + workspace, + }; match tokio::time::timeout(Duration::from_secs(5), client.approve_draft_chunk(req)).await { Ok(Ok(resp)) => { let inner = resp.into_inner(); @@ -1817,12 +1852,14 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { name, chunk_id, reason: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.reject_draft_chunk(req)).await { Ok(Ok(_)) => { @@ -1858,11 +1895,13 @@ fn spawn_draft_approve_all( Some(n) => n.to_string(), None => return, }; + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let req = openshell_core::proto::ApproveAllDraftChunksRequest { name, include_security_flagged: false, + workspace, }; match tokio::time::timeout( Duration::from_secs(30), @@ -1904,15 +1943,41 @@ fn spawn_draft_approve_all( async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; + refresh_workspaces(app).await; refresh_providers(app).await; refresh_sandboxes(app).await; } +async fn refresh_workspaces(app: &mut App) { + let req = openshell_core::proto::ListWorkspacesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + }; + match tokio::time::timeout(Duration::from_secs(5), app.client.list_workspaces(req)).await { + Ok(Ok(resp)) => { + app.workspace_names = resp + .into_inner() + .workspaces + .into_iter() + .filter_map(|w| w.metadata.map(|m| m.name)) + .collect(); + } + Ok(Err(e)) => { + tracing::warn!("failed to list workspaces: {}", e.message()); + } + Err(_) => { + tracing::warn!("list workspaces timed out"); + } + } +} + async fn refresh_providers(app: &mut App) { let profiles = if app.providers_v2_enabled { let req = openshell_core::proto::ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: app.current_workspace.clone(), }; match tokio::time::timeout( Duration::from_secs(5), @@ -1942,6 +2007,12 @@ async fn refresh_providers(app: &mut App) { let req = openshell_core::proto::ListProvidersRequest { limit: 100, offset: 0, + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await; match result { @@ -1981,6 +2052,10 @@ async fn refresh_providers(app: &mut App) { .unwrap_or_else(|| "-".to_string()) }) .collect(); + app.provider_workspaces = providers + .iter() + .map(|p| p.object_workspace().to_string()) + .collect(); if app.provider_selected >= app.provider_count && app.provider_count > 0 { app.provider_selected = app.provider_count - 1; } @@ -2011,6 +2086,7 @@ async fn refresh_global_settings(app: &mut App) { limit: 1, offset: 0, global: true, + workspace: String::new(), }; if let Ok(Ok(resp)) = tokio::time::timeout( Duration::from_secs(5), @@ -2083,6 +2159,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2119,6 +2196,7 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2150,6 +2228,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let raw = edit.input.trim().to_string(); let kind = entry.kind; let mut client = app.client.clone(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { use openshell_core::proto::{SettingValue, UpdateConfigRequest, setting_value}; @@ -2189,6 +2268,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { global: false, merge_operations: vec![], expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2216,6 +2296,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let name = sandbox_name.to_string(); let key = entry.key.clone(); let mut client = app.client.clone(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { use openshell_core::proto::UpdateConfigRequest; @@ -2229,6 +2310,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { global: false, merge_operations: vec![], expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2271,6 +2353,12 @@ async fn refresh_sandboxes(app: &mut App) { limit: 100, offset: 0, label_selector: String::new(), + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; match result { @@ -2347,6 +2435,11 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); + app.sandbox_workspaces = sandboxes + .iter() + .map(|s| s.object_workspace().to_string()) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { app.sandbox_selected = app.sandbox_count - 1; } @@ -2404,6 +2497,7 @@ async fn refresh_draft_chunks(app: &mut App) { let req = openshell_core::proto::GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: app.current_workspace.clone(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_draft_policy(req)).await { @@ -2435,6 +2529,7 @@ async fn refresh_sandbox_draft_counts(app: &mut App) { let req = openshell_core::proto::GetDraftPolicyRequest { name: name.clone(), status_filter: "pending".to_string(), + workspace: app.current_workspace.clone(), }; if let Ok(Ok(resp)) = tokio::time::timeout(Duration::from_secs(2), app.client.get_draft_policy(req)).await diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 9200e8f761..8df6dd3470 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -152,6 +152,10 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" | ", t.muted), ]; + parts.push(Span::styled("Workspace: ", t.text)); + parts.push(Span::styled(app.workspace_display(), t.heading)); + parts.push(Span::styled(" | ", t.muted)); + match app.screen { Screen::Splash => unreachable!("splash handled before draw_title_bar"), Screen::Dashboard => { @@ -260,6 +264,9 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" ", t.text), Span::styled("[c]", t.key_hint), Span::styled(" Create Sandbox", t.text), + Span::styled(" ", t.text), + Span::styled("[w]", t.key_hint), + Span::styled(" Workspace", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index 06a092460c..e4f4d2d0ba 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -15,15 +15,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { return; } - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), Cell::from(Span::styled("CRED KEY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.provider_count) .map(|i| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let name = app.provider_names.get(i).map_or("", String::as_str); let ptype = app.provider_types.get(i).map_or("", String::as_str); let cred_key = app.provider_cred_keys.get(i).map_or("", String::as_str); @@ -41,19 +48,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { ])) }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(ptype, t.muted)), Cell::from(Span::styled(cred_key, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(40), - Constraint::Percentage(25), - Constraint::Percentage(35), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(30), + Constraint::Percentage(20), + Constraint::Percentage(30), + ] + } else { + vec![ + Constraint::Percentage(40), + Constraint::Percentage(25), + Constraint::Percentage(35), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; @@ -89,20 +111,27 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("PROFILE", t.muted)), Cell::from(Span::styled("CATEGORY", t.muted)), Cell::from(Span::styled("CREDS", t.muted)), Cell::from(Span::styled("POLICY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = app .provider_entries .iter() .enumerate() .map(|(i, entry)| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let selected = focused && i == app.provider_selected; let name_cell = if selected { Cell::from(Line::from(vec![ @@ -122,23 +151,40 @@ fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { t.status_warn }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(entry.profile_label(), profile_style)), Cell::from(Span::styled(entry.category_label(), t.muted)), Cell::from(Span::styled(entry.credential_summary(), t.muted)), Cell::from(Span::styled(entry.policy_summary(), t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(22), - Constraint::Percentage(24), - Constraint::Percentage(16), - Constraint::Percentage(18), - Constraint::Percentage(20), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(12), + Constraint::Percentage(18), + Constraint::Percentage(20), + Constraint::Percentage(14), + Constraint::Percentage(16), + Constraint::Percentage(20), + ] + } else { + vec![ + Constraint::Percentage(22), + Constraint::Percentage(24), + Constraint::Percentage(16), + Constraint::Percentage(18), + Constraint::Percentage(20), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; let title = if focused && app.confirm_provider_delete { diff --git a/crates/openshell-tui/src/ui/sandboxes.rs b/crates/openshell-tui/src/ui/sandboxes.rs index 6ace580b8e..4d239241b9 100644 --- a/crates/openshell-tui/src/ui/sandboxes.rs +++ b/crates/openshell-tui/src/ui/sandboxes.rs @@ -10,7 +10,13 @@ use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("STATUS", t.muted)), Cell::from(Span::styled("CREATED", t.muted)), @@ -18,11 +24,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled("IMAGE", t.muted)), Cell::from(Span::styled("LABELS", t.muted)), Cell::from(Span::styled("NOTES", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.sandbox_count) .map(|i| { + let workspace = app.sandbox_workspaces.get(i).map_or("", String::as_str); let name = app.sandbox_names.get(i).map_or("", String::as_str); let phase = app.sandbox_phases.get(i).map_or("", String::as_str); let created = app.sandbox_created.get(i).map_or("", String::as_str); @@ -60,7 +67,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let name_cell = Cell::from(Line::from(name_spans)); - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(phase, phase_style)), Cell::from(Span::styled(created, t.muted)), @@ -68,19 +79,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled(image, t.muted)), Cell::from(Span::styled(labels, t.muted)), Cell::from(Span::styled(notes, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(20), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(8), - Constraint::Percentage(20), - Constraint::Percentage(15), - Constraint::Percentage(12), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(10), + Constraint::Percentage(16), + Constraint::Percentage(9), + Constraint::Percentage(13), + Constraint::Percentage(7), + Constraint::Percentage(18), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + } else { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(8), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index c6fec9aeb8..8d1d8b895b 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -102,6 +102,11 @@ name = "forward_proxy_jsonrpc_l7" path = "tests/forward_proxy_jsonrpc_l7.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "workspace_lifecycle" +path = "tests/workspace_lifecycle.rs" +required-features = ["e2e"] + [[test]] name = "gpu" path = "tests/gpu.rs" diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs new file mode 100644 index 0000000000..bc9f2b0eb1 --- /dev/null +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E tests for workspace resource lifecycle. +//! +//! Covers: +//! - Workspace CRUD +//! - Provider creation scoped to a workspace +//! - Workspace isolation (resources in one workspace are invisible in another) +//! - `--all-workspaces` listing +//! - Deletion guard (workspace cannot be deleted while resources exist) +//! - Successful deletion after resource cleanup + +use std::process::Stdio; + +use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; +use openshell_e2e::harness::output::strip_ansi; + +const WORKSPACE: &str = "lifecycle-test"; +const PROVIDER: &str = "lifecycle-prov"; + +struct CliResult { + output: String, + success: bool, +} + +async fn run_cli(args: &[&str]) -> CliResult { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + CliResult { + output: strip_ansi(&combined), + success: output.status.success(), + } +} + +struct WorkspaceCleanup; + +impl Drop for WorkspaceCleanup { + fn drop(&mut self) { + let bin = openshell_bin(); + let _ = std::process::Command::new(&bin) + .args([ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = std::process::Command::new(&bin) + .args(["workspace", "delete", WORKSPACE]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[tokio::test] +async fn workspace_full_crud_lifecycle() { + let _cleanup = WorkspaceCleanup; + + // 1. Create workspace. + let res = run_cli(&["workspace", "create", "--name", WORKSPACE]).await; + assert!(res.success, "workspace create failed: {}", res.output); + + // 2. Verify workspace exists. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!(res.success, "workspace get failed: {}", res.output); + assert!( + res.output.contains(WORKSPACE), + "workspace get output should contain workspace name: {}", + res.output + ); + + // 3. Create provider in the workspace. + let res = run_cli(&[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "github", + "--runtime-credentials", + "--workspace", + WORKSPACE, + ]) + .await; + assert!( + res.success, + "provider create in workspace failed: {}", + res.output + ); + + // 4. List providers in workspace — should see our provider. + let res = run_cli(&["provider", "list", "--workspace", WORKSPACE]).await; + assert!(res.success, "provider list failed: {}", res.output); + assert!( + res.output.contains(PROVIDER), + "workspace-scoped list should show the provider: {}", + res.output + ); + + // 5. List providers in default workspace — should NOT see our provider. + let res = run_cli(&["provider", "list"]).await; + assert!(res.success, "provider list (default) failed: {}", res.output); + assert!( + !res.output.contains(PROVIDER), + "default workspace should not contain the workspace-scoped provider: {}", + res.output + ); + + // 6. List providers with --all-workspaces — should see our provider. + let res = run_cli(&["provider", "list", "--all-workspaces"]).await; + assert!( + res.success, + "provider list --all-workspaces failed: {}", + res.output + ); + assert!( + res.output.contains(PROVIDER), + "--all-workspaces should include the workspace-scoped provider: {}", + res.output + ); + + // 7. Attempt workspace deletion — should fail because provider exists. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + !res.success, + "workspace delete should fail while resources exist: {}", + res.output + ); + assert!( + res.output.contains("still contains resources"), + "error should mention blocking resources: {}", + res.output + ); + + // 8. Delete the provider. + let res = run_cli(&[ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .await; + assert!(res.success, "provider delete failed: {}", res.output); + + // 9. Workspace deletion should now succeed. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + res.success, + "workspace delete should succeed after resources removed: {}", + res.output + ); + + // 10. Verify workspace is gone. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!( + !res.success, + "workspace get should fail after deletion: {}", + res.output + ); +} diff --git a/proto/datamodel.proto b/proto/datamodel.proto index f92d7b7a36..3fb350ee51 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -26,6 +26,19 @@ message ObjectMeta { // Optimistic concurrency control version. // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; + + // 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. diff --git a/proto/inference.proto b/proto/inference.proto index b0bc581e81..5c8bf44223 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -7,29 +7,30 @@ package openshell.inference.v1; import "datamodel.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. @@ -38,15 +39,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. @@ -60,6 +61,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 { @@ -67,7 +70,7 @@ message ValidatedEndpoint { string protocol = 2; } -message SetClusterInferenceResponse { +message SetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -79,15 +82,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; @@ -95,6 +102,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 d2d884f2e8..2774f02191 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -244,6 +244,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 @@ -447,12 +472,16 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // 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. @@ -461,12 +490,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. @@ -480,6 +515,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. @@ -493,12 +530,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. @@ -585,6 +626,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. @@ -593,6 +636,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. @@ -603,6 +648,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. @@ -616,6 +665,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. @@ -845,17 +896,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. @@ -864,11 +923,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. @@ -885,11 +948,19 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope for two-tier profile resolution. When set, returns merged + // view: workspace-scoped + platform-scoped + built-in. 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. @@ -1032,6 +1103,8 @@ message StoredProviderCredentialRefreshState { message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetProviderRefreshStatusResponse { @@ -1045,6 +1118,8 @@ message ConfigureProviderRefreshRequest { map material = 4; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; + // Workspace scope. Empty defaults to "default". + string workspace = 7; } message ConfigureProviderRefreshResponse { @@ -1054,6 +1129,8 @@ message ConfigureProviderRefreshResponse { message RotateProviderCredentialRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message RotateProviderCredentialResponse { @@ -1063,6 +1140,8 @@ message RotateProviderCredentialResponse { message DeleteProviderRefreshRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message DeleteProviderRefreshResponse { @@ -1117,6 +1196,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. @@ -1136,6 +1218,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. @@ -1148,6 +1233,9 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. Included for API consistency but ignored by the server + // (lint is stateless validation). + string workspace = 2; } // Lint provider profiles response. @@ -1164,6 +1252,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. @@ -1226,6 +1317,8 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; + // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. + string workspace = 9; } message PolicyMergeOperation { @@ -1291,6 +1384,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. @@ -1309,6 +1404,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. @@ -1378,6 +1475,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. @@ -1677,6 +1776,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 { @@ -1698,6 +1799,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 { @@ -1717,6 +1820,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 { @@ -1734,6 +1839,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 {} @@ -1744,6 +1851,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 { @@ -1765,6 +1874,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 {} @@ -1775,6 +1886,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 { @@ -1788,6 +1901,8 @@ message UndoDraftChunkResponse { message ClearDraftChunksRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message ClearDraftChunksResponse { @@ -1799,6 +1914,8 @@ message ClearDraftChunksResponse { message GetDraftHistoryRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message DraftHistoryEntry { @@ -1897,3 +2014,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/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md new file mode 100644 index 0000000000..cf88a3d06b --- /dev/null +++ b/rfc/0011-multi-player-design/README.md @@ -0,0 +1,1225 @@ +--- +authors: + - "@derekwaynecarr" +state: draft +links: + - https://github.com/NVIDIA/OpenShell/issues/1977 +--- + +# RFC 0011 - Multi-Player Support + +## Summary + +This RFC proposes adding multi-user support to OpenShell. Today, sandboxes and +providers are gateway-global with no ownership tracking or isolation between +users. This proposal introduces workspaces as hard isolation boundaries, an +expanded role model (Platform Admin, Workspace Admin, User), workspace-scoped +access, per-workspace quota enforcement, and audit trail enhancements. The Sandbox Supervisor remains a separate principal type +with sandbox-scoped authentication distinct from the user role model. A +`default` workspace preserves backwards compatibility for single-player +deployments. + +## Motivation + +OpenShell is currently a single-player experience. Every authenticated user sees +every sandbox and every provider. There is no concept of resource ownership, +tenant isolation, or delegated administration. This blocks several adoption +scenarios: + +- **Enterprise teams** cannot share a gateway without seeing each other's + sandboxes, credentials, and activity. There is no way to scope visibility + or enforce per-team resource limits. + +- **CI/CD and agent orchestration** workflows need machine identities with + workspace-scoped access. Today the only option is full-privilege OIDC tokens + or mTLS certs with no role granularity beyond admin/user. Workspaces provide + the scoping boundary for these identities. + +- **Compliance and incident response** teams need audit trails that attribute + every sandbox and control-plane action to a specific principal. The existing + OCSF infrastructure logs sandbox-level events but does not consistently tag + them with the creating principal. + +- **Cost attribution** is impossible without ownership metadata. Operators cannot + answer "which team is consuming how many active sandboxes." + +The existing codebase provides a foundation: OIDC authentication, a principal +model (User/Sandbox/Anon), two-tier RBAC, OCSF event infrastructure, and labels +on `ObjectMeta`. The gap is the isolation, ownership, and governance layer on +top. + +Leaving the current design unchanged limits OpenShell to single-operator, +single-team deployments, which constrains adoption and forces organizations +to run one gateway per team. + +## Non-goals + +- **Cross-gateway federation.** This RFC scopes multi-player to a single gateway. + Multi-gateway federation (e.g., routing users to regional gateways) is a + separate concern. +- **Fine-grained ABAC or policy language.** The role model uses coarse-grained + roles with workspace scoping, not attribute-based access control or a policy + DSL like OPA/Rego for authorization decisions. +- **UI/dashboard for user management.** This RFC covers the API and data model. + Administrative UIs are a follow-on. +- **Billing integration.** Principal attribution on resources enables cost + attribution; integration with billing systems is out of scope. +- **Sandbox-to-sandbox networking isolation.** Network isolation between + workspaces at the container/pod level is out of scope; this RFC addresses + control-plane isolation only. +- **Multi-provider OIDC.** This RFC assumes a single configured OIDC provider. + Supporting multiple OIDC providers (e.g., corporate SSO for humans and + GitHub Actions for CI/CD simultaneously) requires issuer-based token routing, + provider-qualified subject formats in membership records, and authenticator + chain changes. These are valuable extensions but are not required for the + core workspace and role model. A follow-on RFC can add multi-provider support + without changing the workspace or membership abstractions. + +## Proposal + +### System Roles + +The role model expands from the current two-tier (admin/user) to three user +roles: + +| Role | Description | +|------|-------------| +| **Platform Admin** | Runtime role with full visibility across all workspaces. Creates workspaces, assigns Workspace Admins, and sets gateway-wide default policies. | +| **Workspace Admin** | Manages users, providers, policies, and quotas within a single workspace. Cannot change gateway infra or access other workspaces. | +| **User** | Creates sandboxes and accesses all sandboxes within assigned workspaces. Uses credentials available in those workspaces. Default role for OIDC-authenticated principals, both human and machine. | + +### Sandbox Supervisor + +The Sandbox Supervisor is not a user role — it is a separate principal type +with its own authentication and authorization path. User roles are properties +of a `Principal::User` and are resolved via OIDC claims or workspace membership +records. The Sandbox Supervisor authenticates as a `Principal::Sandbox` via a +gateway-minted JWT whose subject is bound to a single sandbox UUID. + +The supervisor is scoped to a single sandbox, analogous to a Kubernetes kubelet +identity. It authenticates via a gateway-minted JWT or bootstrap certificate +and is restricted to RPCs that operate on its own sandbox. Authorization is +enforced by a static method allowlist (`is_sandbox_callable`) at the router +layer and per-handler scope guards (`ensure_sandbox_principal_scope`) that +verify the JWT's sandbox UUID matches the request target. The supervisor never +goes through the RBAC role check or workspace membership lookup. + +### Role-to-RPC Access Matrix + +Access is grouped by domain. Within each domain, the access level (none, read, +read-write) applies to all RPCs in that group unless noted otherwise. All user +roles are scoped to their workspace except Platform Admin, which operates +cross-workspace. The Sandbox Supervisor column is included for completeness — +it uses a separate authentication and authorization path (see Sandbox +Supervisor section above). + +| Domain | Platform Admin | Workspace Admin | User | Sandbox Supervisor | +|--------|---------------|-----------------|------|--------------------| +| Workspace lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read (own) | read (own) | none | +| Workspace membership (`Add`, `Remove`, `List`) | read-write | read-write (own ws, no admin assign) | none | none | +| Sandbox lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | read (own sandbox) | +| Sandbox data-plane (`Exec`, `ForwardTcp`, `CreateSshSession`, `RelayStream`) | full | full (own ws) | full (own ws) | none | +| Sandbox observability (`GetSandboxLogs`, `ListSandboxPolicies`, `GetSandboxPolicyStatus`) | read | read (own ws) | read (own ws) | own sandbox | +| Provider management (`Create`, `Get`, `List`, `Update`, `Delete`) | read-write | read-write (own ws) | read (no creds) | none | +| Provider attachment (`Attach`, `Detach`, `ListSandboxProviders`) | read-write | read-write (own ws) | read (own ws) | none | +| Services (`Expose`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | none | +| Gateway config (`GetGatewayConfig`, `UpdateConfig`) | read-write | none | none | none | +| Policy drafts (`SubmitPolicyAnalysis`, `Approve`, etc.) | read-write | read-write (own ws) | none | none | +| Supervisor path (`ConnectSupervisor`, `IssueSandboxToken`, `RefreshSandboxToken`, `GetSandboxProviderEnvironment`, `PushSandboxLogs`, `ReportPolicyStatus`) | none | none | none | own sandbox | + +**Control-plane audit log.** Every mutating gRPC call emits an OCSF +`ApiActivity` event recording the principal, action, target resource, and +timestamp. These events are emitted through the existing OCSF infrastructure +and exported as structured JSONL for consumption by external systems (see +Audit Trail section below). + +### Workspaces + +A workspace is a first-class resource and a hard isolation boundary. Sandboxes, +providers, and policies within a workspace are invisible to other workspaces. +Every resource belongs to exactly one workspace. A `default` workspace exists +for single-player backwards compatibility. Workspace creation is admin-only: +Platform Admins create workspaces and assign Workspace Admins. Self-service +workspace creation can be added later as a gateway configuration option. + +The `Workspace` resource uses standard `ObjectMeta` (the `workspace` field in +its own `ObjectMeta` is unused, following the same convention as Kubernetes +Namespace objects). Workspace-level configuration — quota limits, policy +overrides, and Workspace Admin role bindings — are properties on the Workspace +resource. The gateway exposes `CreateWorkspace`, `GetWorkspace`, +`ListWorkspaces`, and `DeleteWorkspace` RPCs, gated to Platform Admins. +Sandbox and provider create operations validate that the referenced workspace +exists, rejecting unknown workspace values. + +`DeleteWorkspace` is rejected if the workspace contains any workspace-scoped +resources — sandboxes, providers, services, or inference routes. All resources +must be removed before the workspace can be deleted. Membership records are +cleaned up as part of deletion; if any member record fails to delete, the +workspace deletion is aborted and the error is returned to the caller. The +`default` workspace cannot be deleted. Gateway startup fails if the `default` +workspace cannot be created or verified — this is a fatal error, not a +best-effort operation. + +Workspace membership is capped at 1000 members per workspace. +`AddWorkspaceMember` rejects additions that would exceed this limit with a +resource-exhausted error. This cap bounds the cleanup cost during workspace +deletion and ensures the member listing used for cleanup is exhaustive. + +`ObjectMeta` gains a `workspace` field referencing a Workspace by name. Within +a workspace, organizational grouping (teams, projects, cost centers) uses the +existing label system with well-known key conventions (e.g., +`openshell.dev/team=infra`, `openshell.dev/project=alpha`) rather than +additional dedicated fields. This: + +- Gives a clear security boundary (workspace) without over-modeling + organizational hierarchy. +- Allows multiple overlapping groupings within a workspace via labels. +- Keeps the proto surface minimal: `workspace` is the only new field on + `ObjectMeta`. + +#### Workspace use cases + +- **Credential segmentation within a team.** Each user gets their own workspace + on a shared gateway, keeping their API keys (e.g., per-user Claude or Codex + keys) isolated from other users. This eliminates the need for a separate + gateway per user while preserving credential isolation. + +- **Shared coding sessions.** All workspace members can access any sandbox in + the workspace, enabling pair programming and collaborative debugging without + additional access grants. + +- **CI/CD and automation.** A workspace scopes sandbox lifecycle to a specific + pipeline or project. Machine workloads authenticate via the configured OIDC + provider and are added as workspace members with the appropriate role. + +- **Agent harness integration.** A single OpenShell gateway can be partitioned + into discrete workspaces so that multiple agent harness instances (e.g., + OpenClaw) can procure sandboxes from the same gateway with proper isolation. + This removes the requirement for a one-to-one association between an agent + harness instance and an OpenShell gateway instance. + +### Ownership and Access Control + +Access control is based on workspace membership. Principal attribution (who +created or modified a resource) is handled by the control-plane audit log, not +by fields on the resource itself. + +#### Role assignment + +Roles fall into two categories: + +- **Global roles** (Platform Admin) are assigned externally via OIDC claims in + the identity provider (e.g., an `openshell-platform-admin` role in the JWT). + This role is a property of the principal, not of any workspace, and grants + cross-workspace access. The gateway evaluates it from the authenticated + token at request time. + +- **Workspace-scoped roles** (Workspace Admin, User) are assigned internally + via workspace membership records stored in the gateway's durable object + store. These roles are properties of a principal's relationship to a + specific workspace. + +The authorization layer combines both: the gateway first evaluates global roles +from the JWT, then resolves workspace-scoped roles from membership records for +the target workspace. A request to a workspace-scoped RPC is authorized if the +principal has the Platform Admin global role or a +workspace membership with a sufficient role. + +Workspace membership is managed through three RPCs: + +- `AddWorkspaceMember(workspace, principal_subject, role)` — Platform Admins + can assign any workspace-scoped role. Workspace Admins can add Users to + their own workspace but cannot assign the Workspace Admin role. +- `RemoveWorkspaceMember(workspace, principal_subject)` — same access pattern. +- `ListWorkspaceMembers(workspace)` — Platform Admins can list any workspace; + Workspace Admins can list their own. + +Principal subjects are the OIDC `sub` claim from the configured identity +provider. The gateway does not maintain a user directory — membership +references OIDC subjects that are resolved at authentication time. Membership +records are persisted in the durable object store, indexed by both workspace +and principal subject for efficient lookup. + +A principal's request to any workspace-scoped RPC is rejected if they are not a +member of the target workspace. Platform Admins bypass membership checks — +their role grants cross-workspace access by definition. + +#### Resource-level access + +Within a workspace, access varies by resource type: + +- **Sandboxes.** All workspace members can list, get, exec into, and access any + sandbox in their workspace. Credential isolation happens at the workspace + boundary — within a workspace, all members share the same trust domain and + the same provider credentials, so there is no security benefit to restricting + sandbox access by owner. Platform Admins can list across workspaces using + `all_workspaces = true` on list RPCs (see Cross-Workspace List Operations + below). + +- **Providers.** Users can list and reference providers by name within their + workspace but cannot create, update, or delete them, and cannot see raw + credential material. Workspace Admins manage provider lifecycle within their + workspace. `ListProviders` is scoped to the caller's workspace. + +- **Services.** Services are child resources of sandboxes, keyed by sandbox + name. They carry the parent sandbox's workspace in their `ObjectMeta` for + consistent filtering, but the workspace is always inherited from the sandbox, + never set independently. All workspace members can expose, list, and delete + services on any sandbox in their workspace. + +- **Provider profiles.** Provider profiles are type definitions that describe + what a provider type needs (credentials, endpoints, filesystem paths). + Profiles have two-tier scoping: platform-scoped profiles are managed by + Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Built-in profiles (claude-code, + github, nvidia, etc.) are included in both scopes for convenience. Each + scope is listed independently — `ListProviderProfiles` returns profiles + from the requested scope (workspace or platform) plus built-ins, not a + merged view across scopes. This keeps the listing unambiguous: users see + exactly which custom profiles exist in their workspace without conflating + them with platform-level profiles. Import, update, and delete operations + target either platform scope or workspace scope explicitly. + +- **Policies.** Users cannot modify policies directly. Sandbox policy is + derived from attached provider profiles (see Policy Scoping below). + Workspace Admins control policy indirectly by managing which providers are + available in the workspace. `ListSandboxPolicies` is scoped to the caller's + workspace. + +#### Sandbox access within a workspace + +All workspace members have full access to all sandboxes in the workspace. There +is no per-sandbox sharing mechanism within a workspace — the workspace boundary +is the access control surface. Cross-workspace sandbox sharing is deferred to +future work (see Future Work section). + +### Policy Scoping + +Sandbox policy is derived from the providers attached to the sandbox. There is +no separate workspace-level policy to author or maintain — the providers a +Workspace Admin makes available in the workspace define what sandboxes in that +workspace can do. + +| Layer | Scope | Set by | Purpose | +|-------|-------|--------|---------| +| Gateway default | All sandboxes | Platform Admin | Enforcement modes (Landlock) and gateway-wide network deny rules | +| Provider profiles | Per sandbox | Workspace Admin (provider lifecycle) / User (provider attachment) | Network endpoints, filesystem paths, environment variables | + +Each provider carries a profile describing the endpoints and filesystem paths +it requires. When a user attaches providers to a sandbox, the gateway computes +the effective policy as the union of the attached provider profiles, layered on +top of the gateway default. The gateway returns a single resolved +`SandboxPolicy` at `GetSandboxConfig` time — the sandbox sees a flat policy, +not the composition. + +Provider types fall into two categories: + +- **Credential providers** (Anthropic, OpenAI, GitHub) — inject secrets and add + the provider's network endpoints to the sandbox policy. +- **Endpoint providers** — add network endpoints only, no secrets. Used for + internal services (Git servers, artifact registries, custom APIs) that + sandboxes need to reach but that don't require credential injection. + +Both types use the same provider abstraction. The Workspace Admin's policy +decision reduces to: "which providers does this workspace have?" The User's +sandbox-level policy decision is: "which of those providers does my sandbox +use?" + +The gateway default (enforcement modes, deny rules) remains the floor and +cannot be overridden by provider profiles. Deny rules at the gateway level +override provider profile allows. + +**Migration.** Existing global policies map to the gateway default. Existing +per-sandbox policies continue to work through the policy advisor flow, which +generates policy from attached providers. The `restrictive_default_policy()` +fallback applies when no providers are attached — identical to current behavior. + +### Authorization Enforcement + +The gateway's existing authorization pattern — compile-time per-method +metadata, middleware authentication, and per-handler guards — extends to +workspace-scoped enforcement without architectural changes. + +**Proto-driven method metadata.** Authorization rules are declared as custom +options on each proto RPC method, making the proto definition the single +source of truth for the API contract and its access control: + +```proto +import "google/protobuf/descriptor.proto"; + +message AuthorizationRule { + string auth_mode = 1; // "bearer", "sandbox", "dual", "unauthenticated" + string workspace_role = 2; // "user", "admin" + string global_role = 3; // "platform_admin" +} + +extend google.protobuf.MethodOptions { + AuthorizationRule authorization = 50000; +} +``` + +Each RPC carries its authorization requirement: + +```proto +service OpenShell { + rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "user" }; + } + rpc CreateProvider(CreateProviderRequest) returns (CreateProviderResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "admin" }; + } + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse) { + option (authorization) = { auth_mode: "bearer", global_role: "platform_admin" }; + } + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) { + option (authorization) = { auth_mode: "sandbox" }; + } +} +``` + +The gateway already compiles a `FileDescriptorSet` at build time and embeds +it in the binary (`openshell_core::FILE_DESCRIPTOR_SET`). Adding +`prost_reflect::DescriptorPool` allows the runtime to resolve custom +extensions natively — no build.rs code generation, no external tooling: + +```rust +static DESCRIPTOR_POOL: LazyLock = LazyLock::new(|| { + DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor pool") +}); +``` + +At startup the middleware walks the pool's methods, reads the +`(authorization)` extension from each `MethodDescriptor::options()`, and +builds the lookup table keyed by gRPC method path. This replaces the current +`#[rpc_authz]` proc macro and per-service `AUTH_METADATA` tables — the proto +definition becomes the single source of truth for both the API contract and +its access control. The middleware calls the same `method_authz::lookup()` +function at request dispatch time; only the source of the table changes. + +The existing exhaustiveness tests switch from `prost_types::FileDescriptorSet` +to `DescriptorPool` and assert that every method in the pool carries a valid +`(authorization)` option, catching missing annotations at `cargo test` time. + +This follows the pattern established by `google.api.http` annotations for +REST gateway generation: the proto carries the metadata, the descriptor pool +resolves it, and the runtime consumes it directly. + +**Workspace on every scoped request.** Since resource names are +unique-within-workspace, every workspace-scoped RPC includes the workspace in +its request message. A `WorkspaceScoped` trait implemented on each request type +provides uniform access: + + trait WorkspaceScoped { + fn workspace(&self) -> &str; + } + +**Single authorization path.** A shared `authorize_workspace` function replaces +per-handler authorization boilerplate. It extracts the principal from request +extensions, checks for Platform Admin global role bypass, resolves +workspace membership from the durable store, and verifies the membership role +meets the method's declared minimum: + + let principal = authorize_workspace( + &request, WorkspaceRole::User, &self.membership, + )?; + +Every workspace-scoped handler uses this one-line call. The middleware layer +is unchanged: it authenticates the caller, inserts the principal into request +extensions, and the handler resolves workspace authorization. + +### Authorization Boundaries (Kubernetes Deployments) + +In Kubernetes deployments, authorization operates at two layers. Kubernetes +RBAC governs control-plane operations: who can create, delete, or manage +sandbox custom resources and other objects in a Kubernetes namespace. The +gateway's role model governs data-plane operations: who can exec into a +running sandbox, stream relay output, or view audit logs. +These are runtime authorization decisions through the gateway's gRPC endpoints +where Kubernetes RBAC has no reach. + +Both layers are needed in Kubernetes deployments with clear boundaries between +them. For non-Kubernetes drivers (Docker, Podman, VM), the gateway's role model +is the sole authorization mechanism. + +### Provider Credential Scoping + +Providers belong to a workspace. A User can only attach providers available in +their workspace when creating a sandbox. No role sees raw credential material +through the API; all roles reference providers by +name. The sandbox supervisor resolves credentials at runtime through an internal +trusted path, not through role-level permissions. + +### Authentication + +The gateway authenticates principals via its existing OIDC provider. The +workspace and role model does not change the authentication mechanism — it +layers authorization on top of the authenticated identity. + +When OIDC is configured, the gateway validates the Bearer token, extracts the +`sub` claim as the principal subject, and evaluates global roles from JWT +claims (e.g., Platform Admin). Workspace-scoped roles are resolved from +membership records keyed by the principal's `sub` claim. + +When OIDC is not configured, every request is treated as a Platform Admin +principal. The full workspace model remains available — workspaces can be +created, members added, resources scoped — but no authentication or +authorization checks are enforced. This preserves the current single-player +experience and allows operators to adopt workspaces for organizational +structure before enabling authentication. + +### Audit Trail + +Multi-player introduces multiple principals acting on shared infrastructure. +The audit trail must attribute every action to a specific principal so that +security teams, compliance reviewers, and operators can answer "who did what, +when, and in which workspace" without needing a gateway role to do so. + +OpenShell already has an OCSF event infrastructure with two output layers: +a shorthand formatter for human-readable logs and a JSONL formatter for +structured machine consumption. Multi-player extends this infrastructure +with principal and workspace attribution on every event. + +**Control-plane events.** Every mutating gRPC call (`CreateSandbox`, +`DeleteSandbox`, `CreateProvider`, `UpdatePolicy`, `AddWorkspaceMember`) +emits an OCSF event with the authenticated principal's subject, the target +workspace, the action, the target resource, and a timestamp. `ApiActivity` +is a new OCSF event class that must be added to the `openshell-ocsf` crate. +`ConfigStateChange` covers policy and configuration mutations. These events +are emitted through the existing OCSF infrastructure — no separate audit +storage or query API is required. + +**Sandbox-level events.** Sandbox activity (network decisions, process +lifecycle, SSH sessions) is already emitted as OCSF events by the sandbox +supervisor. Multi-player adds the creating principal's subject to these +events so security teams can trace sandbox behavior back to the human or +machine principal that created it. + +**Log forwarding.** The gateway writes OCSF events as structured JSONL to +a configurable output (file, stdout). Operators forward this JSONL to their +SIEM or log aggregation system (Splunk, Elastic, Datadog, CloudWatch) using +standard log shipping tools (Fluentd, Vector, Filebeat). The JSONL format +follows OCSF v1.7.0 schema conventions, making it directly ingestible by +SIEM platforms that support OCSF. + +This model follows the Kubernetes pattern: the API server writes audit events +to a log backend, and external tooling handles aggregation, retention, and +querying. Audit consumers do not need a gateway role — they access audit data +through their organization's log infrastructure. Queries like "who created +sandbox X" or "what did user Y do between T1 and T2" are answered in the SIEM, +not through a gateway API. + +### Resource Governance + +- **Per-workspace quotas.** Max concurrent sandboxes, max GPU allocations, max + sandbox lifetime per workspace. Enforced at the gateway before sandbox + creation. Quota limits are hard — sandbox creation is rejected when a quota + is exceeded. Quotas are framed as DoS and abuse protection for the control + and data plane, not as a chargeback mechanism. Quota limits are properties on the + workspace data model; detailed schema design is deferred to implementation. + +### Kubernetes Compute Driver: Workspace Mapping + +OpenShell workspaces are a gateway-level concept. The gateway populates the +workspace on each `DriverSandbox` passed to the compute driver. Drivers consume +the workspace to map it to the appropriate infrastructure-level isolation (K8s +namespace, Docker label, etc.) but do not define or manage workspaces +themselves. When the Kubernetes compute driver renders sandboxes onto a cluster, +it must map each OpenShell workspace to a Kubernetes namespace. The driver +supports two modes, configured per deployment: + +**Managed mode** (default) — the driver creates and deletes Kubernetes +namespaces on demand. The Kubernetes namespace name is derived from the gateway +identifier and the OpenShell workspace: +`openshell-{gateway-id}-{workspace-name}`. For example, if the gateway +identifier is `prod` and the OpenShell workspace is `team-ml`, the Kubernetes +namespace is `openshell-prod-team-ml`. + +The gateway identifier prefix ensures that multiple gateways can operate on a +common Kubernetes cluster without namespace collisions. Each gateway owns its +own set of Kubernetes namespaces and can independently create, watch, and delete +them. The gateway identifier is already part of the gateway's bootstrap +configuration. + +When an OpenShell workspace is deleted and all sandboxes have been removed, the +driver deletes the corresponding Kubernetes namespace. + +Managed mode requires a `ClusterRole` with namespace create/delete permissions. +The Helm chart includes conditional `ClusterRole` and `ClusterRoleBinding` +templates that are enabled by default. Workspace names must be validated at +creation time to ensure the resulting Kubernetes namespace name is DNS-1123 +compliant (lowercase alphanumeric and hyphens, max 63 characters total +including the `openshell-{gateway-id}-` prefix). The gateway rejects workspace +names that would produce invalid or colliding Kubernetes namespace names. + +**Operator mode** — an alternative for environments where the gateway should not +create Kubernetes namespaces. The OpenShell workspace name maps one-to-one to a +Kubernetes namespace of the same name. If a sandbox belongs to OpenShell +workspace `team-ml`, the driver renders it into the Kubernetes namespace +`team-ml`. No mapping configuration is required. The Kubernetes namespaces must +be pre-provisioned — the driver has no permission to create or delete them. +If the target Kubernetes namespace does not exist, the driver lets the +Kubernetes API reject the request and surfaces the error — no pre-validation, +which avoids TOCTOU races. + +This direct identity mapping enables the OpenShell gateway to operate as a +natural Kubernetes-style operator: it receives a desired state (sandbox in +workspace X) and renders it into the corresponding cluster namespace. Platform +teams manage Kubernetes namespaces through their existing tooling (kubectl, +GitOps, Terraform) and OpenShell follows. + +```toml +[openshell.drivers.kubernetes] +workspace_mode = "operator" # opt-in; default is "managed" +``` + +**Watcher strategy.** Today the Kubernetes driver watches a single Kubernetes +namespace via `Api::namespaced_with()`. With multiple workspaces, the driver +shifts to a cluster-wide list/watch filtered by OpenShell labels (e.g., +`openshell.dev/managed-by=gateway`). This follows the standard Kubernetes +operator pattern for multi-namespace controllers. A per-namespace watcher +approach does not scale — it requires O(n) API connections and complicates +dynamic workspace addition/removal. The cluster-wide watch requires a +`ClusterRole` granting list/watch across Kubernetes namespaces (applicable to +both operator and managed modes). + +**Docker and Podman drivers.** The Docker driver's `sandbox_namespace` label +provides a foundation for workspace mapping, but the driver currently uses a +single configured namespace rather than per-sandbox values. The driver contract +must be updated so that workspace flows through `DriverSandbox` and the driver +applies it as the container label filter. The same applies to Podman and other +local drivers — workspace isolation is enforced at the gateway level and does +not require Kubernetes. + +### Compute Driver Trust Model + +The `ComputeDriver` gRPC service is a gateway-internal contract between the +gateway and its compute backend. Drivers can be in-process (Docker, Podman) or +out-of-process (VM driver subprocess, remote Kubernetes driver). The trust model +for this channel is: + +**The driver is a trusted backend.** The gateway is the sole caller of the +driver service. The driver does not enforce workspace isolation — it operates on +`DriverSandbox` messages keyed by `(id, name, namespace)` and has no concept of +workspaces. Workspace is a gateway-level tenancy boundary that the gateway +resolves before dispatching to the driver. The driver trusts the gateway to have +already authenticated the user, authorized the operation, and resolved the +workspace-to-namespace mapping. + +**Gateway-to-driver authentication.** For in-process drivers, no authentication +is needed — the driver runs in the gateway process. For out-of-process drivers, +the gateway authenticates to the driver via mTLS or a shared credential +configured at deployment time. This is a service-to-service trust boundary, not +a user-facing one. The `RemoteComputeDriver` connects over a gRPC channel; the +channel's transport security governs the trust. + +**Multi-gateway deployments.** When multiple gateways share a compute backend +(e.g., a shared Kubernetes cluster), each gateway independently manages its own +workspace set and namespace mappings. The driver has no mechanism to enforce +cross-gateway workspace isolation — it sees containers/pods from all gateways +indiscriminately. Isolation between gateways relies on the deterministic +namespace naming convention (`openshell-{gateway-id}-{workspace-name}`) ensuring +non-overlapping Kubernetes namespaces, and on OpenShell labels that scope +list/watch results to a specific gateway's resources. + +**The driver does not need workspace-scoped RPCs.** The `ComputeDriver` service +contract remains workspace-unaware. `CreateSandbox` receives a `DriverSandbox` +with the resolved Kubernetes namespace (or Docker label). `ListSandboxes` returns +all platform-observed sandboxes — the gateway correlates them back to workspaces. +Adding workspace awareness to the driver contract would violate the separation +between the gateway's tenancy model and the driver's infrastructure model. + +### Cross-Workspace Infrastructure Operations + +Several gateway-internal operations must query workspace-scoped resources across +all workspaces. These operations run as the gateway process itself — they are +not user-initiated gRPC calls and do not go through the authentication or +workspace authorization path. The gateway is the actor. + +**Affected operations:** + +- **Sandbox reconciliation** (`reconcile_store_with_backend`). The reconciler + periodically compares all stored sandbox records against the compute driver's + inventory to detect orphans (store records with no driver-side resource) and + ghost resources (driver resources with no store record). The driver's + `ListSandboxes` returns all platform-observed sandboxes regardless of + workspace — the driver has no workspace concept. The gateway must query all + stored sandboxes across all workspaces to produce the full set for comparison. + +- **Startup resume** (`resume_persisted_sandboxes`). On gateway startup, the + resume path iterates all stored sandboxes whose phase indicates they should + be running and asks the driver to resume each one. This must cover all + workspaces. + +- **Provider credential refresh** (`refresh_provider_credential`). A background + worker iterates `StoredProviderCredentialRefreshState` records to refresh + expiring credentials. These refresh state records are globally scoped (not + workspace-scoped), but the worker must look up the corresponding `Provider` + resource, which is workspace-scoped. With multiple workspaces, the same + provider name can exist in different workspaces, so the refresh state must + carry the provider's workspace to resolve the correct one unambiguously. + +**Store-level cross-workspace query.** The persistence layer gains a +`list_by_type(object_type, limit, offset)` method that omits the workspace +filter. This is distinct from the workspace-scoped `list(object_type, workspace, +limit, offset)` used by gRPC handlers. The cross-workspace query is used only +by internal infrastructure operations — it is not exposed through any gRPC RPC +directly. + +**Workspace resolution on driver watch events.** When the driver reports a +sandbox that does not exist in the store (a watch event for a sandbox the +gateway has no record of), the gateway creates a new store record. The workspace +for this record is resolved by reverse-mapping the `DriverSandbox.namespace` +field through the workspace-to-namespace configuration: + +- **Managed mode**: the gateway parses the Kubernetes namespace name + (`openshell-{gateway-id}-{workspace-name}`) to extract the workspace. +- **Operator mode**: the gateway looks up which workspace maps to the reported + Kubernetes namespace via the identity mapping. +- **Docker/Podman**: the gateway reads the workspace from the container label. + +If no mapping matches, the sandbox is assigned to the `default` workspace. This +is a best-effort fallback for cases like manually created containers or +resources from a prior gateway configuration. + +### Cross-Workspace List Operations + +Platform Admins need the ability to list resources across all workspaces, +analogous to `kubectl get pods --all-namespaces`. This is an explicit opt-in +on list RPCs, not the default behavior. + +**RPC mechanism.** Workspace-scoped list RPCs (`ListSandboxes`, +`ListProviders`, `ListServices`) gain an `all_workspaces` boolean field. When +`all_workspaces = true`, the handler bypasses workspace scoping and returns +results from all workspaces. The caller must have the Platform Admin global role; +workspace-scoped roles cannot set `all_workspaces`. Results include the +`workspace` field in each resource's `ObjectMeta` so the caller can distinguish +provenance. + +This is distinct from passing an empty workspace string. Empty workspace is +resolved to `"default"` by the gateway's `resolve_workspace()` logic for +backwards compatibility — it does not mean "all workspaces." + +**Store query.** The `all_workspaces` handler path uses the same +`list_by_type(object_type, limit, offset)` store method as the internal +infrastructure operations. The authorization gate (Platform Admin check) is +enforced at the gRPC handler level, not at the store level — the store method +itself is access-control-unaware. + +**CLI surface.** The `--all-workspaces` flag is available on list commands for +Platform Admins: + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +### Enterprise Deployment: Multi-Consumer Gateway + +A common enterprise deployment pattern — particularly in regulated industries +like financial services and defense — involves one or two data centers, each +running a handful of Kubernetes clusters. In this environment, organizations +want to minimize the number of OpenShell gateways they need to reason about. +Workspaces enable a single gateway per compute region to serve multiple +independent sandbox consumers with proper isolation between them. + +**Multiple consumers, one gateway.** In a single enterprise OpenShell +deployment, many independent consumers procure sandboxes on demand — agent +harnesses, CI/CD pipelines, internal tooling, and interactive users. Using +OpenClaw as an example agent-harness consumer that needs to procure sandboxes +on demand, an OpenClaw instance adds a single `workspace` field to its +OpenShell plugin config: + +```json5 +// OpenClaw plugin config — workspace scopes all sandbox operations +{ + plugins: { + entries: { + openshell: { + enabled: true, + config: { + from: "openclaw", + mode: "remote", + gateway: "prod", + gatewayEndpoint: "https://openshell.internal:8443", + workspace: "team-capital-markets", + }, + }, + }, + }, +} +``` + +The plugin passes `--workspace` to every `openshell` CLI invocation (`sandbox +get`, `sandbox create`, `sandbox list`). The rest of the OpenClaw integration +— sandbox lifecycle, SSH transport, workspace sync — is unchanged. + +OpenClaw sandboxes for tool execution and agent sessions are provisioned within +the assigned workspace. Other consumers — a different OpenClaw instance for +another department, a separate agent harness, or a CI pipeline — each operate +in their own workspace on the same gateway. Sandbox list operations are +workspace-scoped: a consumer sees only its own sandboxes, never sandboxes +belonging to other consumers. + +This is not OpenShell absorbing multiplayer concerns from its consumers. +OpenClaw and other agent harnesses own their own multi-user models. The +requirement on the OpenShell side is narrower: a single gateway must support +1:N partitioning so that each consumer's sandboxes are properly isolated from +every other consumer's sandboxes, without requiring a dedicated gateway per +consumer. + +**Kubernetes-level isolation chain.** When the gateway renders sandboxes onto +the cluster, each workspace maps to a discrete Kubernetes namespace. This +enables the Kubernetes isolation stack when the cluster is configured for it: + +- **Network policy** can partition traffic between Kubernetes namespaces, + preventing cross-workspace network access between sandboxes (requires a CNI + that enforces NetworkPolicy). +- **UID/GID range allocation** (as enforced on platforms like OpenShift) assigns + each Kubernetes namespace a unique UID/GID range. Every sandbox process runs + under a UID that is unique to its workspace's namespace. +- **SELinux labeling** (on OpenShift and similarly configured platforms) assigns + each Kubernetes namespace a unique SELinux label (MCS category). Kernel-level + mandatory access control constrains processes to their namespace's domain. + +These are platform prerequisites, not features provisioned by OpenShell. The +workspace-to-namespace mapping provides the structure; the cluster must be +configured to enforce isolation at each layer. + +**Sandbox escape threat model.** Container breakout is the dominant concern in +regulated environments. The workspace-to-Kubernetes-namespace mapping means +that even in the event of a sandbox escape, the attacker's process carries the +UID/GID and SELinux label of the originating workspace's Kubernetes namespace. +On platforms that enforce these boundaries at the node level, a compromised +process is constrained by: + +- Kubernetes RBAC and secrets scoped to the originating namespace. +- UID/GID range enforcement preventing access to other namespaces' resources. +- SELinux MCS labels preventing cross-namespace process and file access. + +The combination of gateway-level workspace isolation (control plane) and +Kubernetes namespace isolation (data plane) produces defense in depth: even if +one layer is compromised, the other constrains the blast radius to a single +workspace. + +### CLI Surface + +All sandbox and provider commands accept an optional `--workspace` flag that +scopes operations to a specific workspace. When omitted, the CLI defaults to +the `default` workspace, preserving the single-player experience. + +The workspace can also be set via the `OPENSHELL_WORKSPACE` environment +variable. The explicit `--workspace` flag takes precedence over the environment +variable. + +```shell +openshell sandbox create --workspace team-ml --name my-sandbox +openshell sandbox list --workspace team-ml +openshell provider list --workspace team-ml +``` + +#### Cross-workspace listing + +Platform Admins can list resources across all workspaces using the +`--all-workspaces` flag. This is mutually exclusive with `--workspace`. Output +includes the workspace in each row so the admin can distinguish provenance. + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +#### Provider profile scope flag + +Provider profiles use two-tier scoping (see Resource-level access above). +Because `--workspace` defaults to the `default` workspace, a separate +`--global` flag distinguishes platform-scoped operations from workspace-scoped +ones. The two flags are mutually exclusive. + +| Flag | Scope | Who | Behavior | +|------|-------|-----|----------| +| *(neither)* | Workspace (`default`) | Workspace Admin | Operates on workspace-scoped profiles; list returns workspace custom + built-in | +| `--workspace team-ml` | Workspace (`team-ml`) | Workspace Admin | Same, targeting a specific workspace | +| `--global` | Platform | Platform Admin | Operates on platform-scoped profiles; list returns platform custom + built-in | + +```shell +# Platform Admin imports an org-wide custom profile (platform-scoped) +openshell provider profile import --global -f internal-gitlab.yaml + +# Workspace Admin imports a team-specific profile (workspace-scoped) +openshell provider profile import --workspace team-ml -f team-registry.yaml + +# Workspace custom + built-in (does not include platform-scoped) +openshell provider profile list --workspace team-ml + +# Platform custom + built-in (does not include workspace-scoped) +openshell provider profile list --global +``` + +## Implementation plan + +The implementation builds on the existing authentication, RBAC, and OCSF +foundations. The work can be phased to deliver value incrementally: + +- **Phase 1: Workspace and membership model.** Add the `Workspace` resource + with standard `ObjectMeta` and `CreateWorkspace`, `GetWorkspace`, + `ListWorkspaces`, `DeleteWorkspace` RPCs gated to Platform Admins. Add + `workspace` field to `ObjectMeta` for Sandbox and Provider resources, + validated against existing workspaces on create. All workspace-scoped + resources inherit workspace from their parent sandbox or workspace context: + services, SSH sessions, policy revisions, policy drafts, settings, provider + refresh state, inference routes, audit records, and log/watch streams. + Provider profiles use two-tier scoping: platform-scoped profiles are managed + by Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Each scope is listed independently + — `ListProviderProfiles` returns profiles from the requested scope plus + built-ins, not a merged view across scopes. Built-in profiles are included + in both scopes for convenience. Implement workspace-scoped + storage and filtering in gRPC handlers. Add the membership store with `(workspace, principal_subject) → + role` records and `AddWorkspaceMember`, `RemoveWorkspaceMember`, + `ListWorkspaceMembers` RPCs. Create the `default` workspace on gateway + startup for backwards compatibility. Sandbox name uniqueness shifts from + globally unique to unique-within-workspace. The current global uniqueness + constraint `(object_type, name)` shifts to `(object_type, workspace, name)`. + Existing resources are backfilled to the `default` workspace during + migration. Service endpoint hostnames always include workspace + (`{workspace}--{sandbox}--{service}.{base-domain}`), including the `default` + workspace. Always including the workspace eliminates hostname parsing + ambiguity — with a variable number of `--`-delimited segments, the parser + cannot distinguish `{sandbox}--{service}` from `{workspace}--{sandbox}` + without knowing whether the default workspace was omitted. The consistent + three-segment format makes parsing unambiguous: two segments is + `{workspace}--{sandbox}`, three is `{workspace}--{sandbox}--{service}`. + Backward compatibility is desirable but not a hard requirement + at this stage — existing users may need to recreate resources when upgrading. + Add a cross-workspace `list_by_type(object_type, limit, offset)` store method + for internal infrastructure operations (reconciler, resume, provider refresh) + that need to query workspace-scoped resources across all workspaces. Thread + workspace through `StoredProviderCredentialRefreshState` so the provider + refresh worker can unambiguously resolve workspace-scoped providers — with + multiple workspaces, `provider_name` alone is insufficient because different + workspaces can have same-named providers. Add `all_workspaces` field to + workspace-scoped list RPCs for Platform Admin cross-workspace visibility. + Add `ObjectWorkspace::requires_workspace()` trait method and `debug_assert!` + validation in store write helpers to catch workspace-scoped resources + persisted with an empty workspace in debug builds. + +- **Phase 2: Expanded role model and authorization enforcement.** Extend the + RBAC system from two-tier (admin/user) to three user roles (Platform Admin, + Workspace Admin, User). Add proto-driven authorization metadata via custom + method options and `prost_reflect::DescriptorPool`. Implement + `authorize_workspace()` and `WorkspaceScoped` trait for workspace-scoped + access guards in gRPC handlers. Replace the `#[rpc_authz]` proc macro with + descriptor pool-based lookup. Add Workspace Admin role with per-workspace + management capabilities. + +- **Phase 3: Kubernetes driver — managed mode (default).** The driver creates + Kubernetes namespaces on demand using the naming convention + `openshell-{gateway-id}-{workspace-name}`. The watcher shifts from + single-namespace `Api::namespaced_with()` to cluster-wide list/watch with + OpenShell label filtering. Once all sandboxes in a workspace are deleted, + the driver deletes the corresponding Kubernetes namespace. Helm chart adds + `ClusterRole` and `ClusterRoleBinding` for namespace create/delete and + multi-namespace list/watch permissions (enabled by default). Includes + idempotent create with retry to handle races. The reconciler and watch + event handler use the cross-workspace `list_by_type` store query (from + Phase 1) to compare driver inventory against all stored sandboxes. When + the driver reports a sandbox not in the store, the gateway resolves its + workspace by reverse-mapping `DriverSandbox.namespace` through the + workspace-to-namespace configuration (parsing the managed-mode naming + convention or looking up the operator-mode identity mapping). + +- **Phase 4: Kubernetes driver — operator mode.** Alternative mode where the + OpenShell workspace name maps one-to-one to a pre-existing Kubernetes + namespace. The driver accepts per-sandbox workspaces from the gateway + (populated via `driver_sandbox_from_public()`) and renders sandboxes into the + corresponding Kubernetes namespace. No namespace create/delete permissions + required. Opt-in via `workspace_mode = "operator"` in the driver config. + Shares the cluster-wide watcher infrastructure from Phase 3. + +- **Phase 5: Audit trail enhancements.** Add `ApiActivity` OCSF event type for + control-plane mutations. Tag all sandbox activity events with the + authenticated principal's subject and workspace. Extend OCSF JSONL export + with principal and workspace attribution fields. + +- **Phase 6: Quota enforcement.** Implement per-workspace quota checks at the + gateway. Add quota configuration surface for Platform Admins and Workspace + Admins. Quota limits are stored as workspace properties and usage counters + are tracked in the existing durable object store. + +Phases 1 and 2 are sequential prerequisites — Phase 2 depends on Phase 1. +Phase 4 depends on Phase 3 (shared watcher infrastructure). Phases 3, 5, and 6 +can be reordered relative to each other based on priority. Phases 5 and 6 +depend only on Phase 1. + +## Risks + +- **Migration complexity.** Existing deployments have no workspace concept. The + `default` workspace provides backwards compatibility, but platform teams with + established workflows may need to re-organize resources when adopting + workspaces. Migration tooling and documentation will be needed. + +- **Proto surface growth.** Adding `workspace` and role-related fields to the + proto increases the API surface that must be maintained across versions. The + design intentionally keeps new proto fields minimal (`workspace` on + `ObjectMeta`) and uses labels for soft grouping to limit this. + +- **RBAC complexity.** Three roles with workspace scoping is significantly more + complex than the current two-tier model. Misconfiguration could lead to + privilege escalation or overly restrictive access. Clear defaults, validation, + and documentation are essential. + +- **Performance at scale.** Workspace-scoped filtering and quota enforcement add + per-request overhead. For deployments with many workspaces and users, the + filtering and quota checks must be efficient. Indexing strategies need + consideration during implementation. + +- **Quota enforcement races.** Concurrent sandbox creation within a workspace + could race against quota limits. The quota check and sandbox creation must be + atomic or use optimistic concurrency control with retry. + +- **Kubernetes ClusterRole requirements.** Both operator and managed modes require + a `ClusterRole` for cluster-wide list/watch. Managed mode additionally + requires namespace create/delete permissions. Some clusters restrict these + grants. The Helm chart must make these conditional and clearly documented. + +- **Managed mode race conditions.** Kubernetes namespace creation is async. + Sandbox creation may race against it. The naming convention + (`openshell-{gateway-id}-{workspace-name}`) is deterministic, so concurrent + creates from the same gateway are idempotent. + +- **In-flight sandboxes during workspace deletion.** Workspace deletion is + rejected if active sandboxes exist. Once all sandboxes are removed, the + driver deletes the corresponding Kubernetes namespace. + +- **Multi-gateway coordination.** The `openshell-{gateway-id}-{workspace-name}` + naming convention partitions Kubernetes namespaces by gateway, so multiple + gateways can share a cluster without collisions. However, this means each + gateway manages its own workspace set independently — cross-gateway workspace + visibility requires external coordination. + +- **Cross-workspace store query authorization.** The `list_by_type` store method + has no access-control gate — it is a persistence-layer primitive. Authorization + for cross-workspace queries is enforced at the gRPC handler level (Platform + Admin check for `all_workspaces` on list RPCs) and by code-level access + control for internal operations (only the reconciler, resume, and refresh + worker call it). This relies on internal code discipline rather than an + enforced store-level boundary. A future extension could add a store-level + caller identity parameter if defense-in-depth is desired. + +- **Remote compute driver channel security.** The `RemoteComputeDriver` gRPC + channel does not currently enforce authentication. For deployments where the + driver runs as a separate service (rather than a co-located subprocess), the + channel should use mTLS or a shared credential. Without transport security, + any network-adjacent process could impersonate the gateway to the driver. + +## Future Work + +### Cross-workspace sandbox sharing + +This design treats the workspace as the access control boundary — all workspace +members have full access to all sandboxes in the workspace, and there is no +per-sandbox sharing mechanism within a workspace. + +A future extension could allow sharing a sandbox with a principal who is not a +member of the workspace. The motivating use case: a platform team runs a +"shared-tools" workspace containing sandboxes with internal services (a test +database, a mock API, a reference environment). Engineers in other workspaces +need exec access to specific sandboxes in shared-tools without becoming full +members of that workspace — full membership would grant them access to all +sandboxes and providers in shared-tools, which is broader than needed. + +This would require a scoped access grant that gives the target principal access +to a specific sandbox without conferring workspace membership. The grant would +need to be auditable, revocable, and limited to the specific sandbox — not a +general workspace bypass. Design considerations include whether the sharee can +list other resources in the workspace, how provider credential exposure is +handled (the sandbox environment may already have credentials injected), and +whether the grant survives sandbox recreation. + +### Built-in profile visibility scoping + +Built-in profiles (claude-code, github, nvidia, etc.) are currently included +in both workspace-scoped and platform-scoped profile listings. This is +convenient but may be misleading — a Workspace Admin seeing built-in profiles +in their workspace listing might assume they can modify or override them at +the workspace level. + +A future change could restrict built-in profiles to the platform-scoped +listing only (`--global`). Workspace-scoped listings would show only custom +profiles that have been explicitly imported into that workspace. This would +make the listing semantics cleaner: workspace scope shows what the Workspace +Admin has configured, platform scope shows what the Platform Admin and the +system provide. The trade-off is discoverability — new users listing profiles +in their workspace would see nothing until a Workspace Admin imports profiles, +which could be confusing for single-player deployments. + +### Machine identity via OIDC workload identity + +CI/CD pipelines, agent harnesses, and other machine workloads could +authenticate to the gateway using OIDC workload identity tokens issued by +their platform (GitHub Actions, GitLab CI, GCP, AWS). The gateway would +validate these tokens and resolve the token's `sub` claim to a workspace +membership, following the same `authorize_workspace` path as human principals. +No stored API keys or service account secrets would be required. + +The most common deployment — human users authenticating via corporate SSO +and machine workloads authenticating via CI/CD platform OIDC — requires +multi-provider OIDC support (see Non-goals). The workspace and membership +model introduced in this RFC is designed to support workload identity without +changes: a machine workload's OIDC subject is added as a workspace member +with the User role, and the standard authorization path applies. + +## Alternatives + +### Flat label-based tenancy (no workspaces) + +Use labels alone for all isolation, without a first-class workspace concept. +Users would filter by label, and access control would use label selectors. + +This was rejected because labels are a soft grouping mechanism with no +enforcement guarantee. A mislabeled resource would be visible across tenant +boundaries. Hard isolation requires a first-class field that the system enforces +at every access point, not a convention that depends on correct labeling. + +### One gateway per team + +Instead of multi-tenancy, deploy separate gateways per team. This provides +complete isolation by default. + +This was rejected because it creates operational overhead (N gateways to +manage), prevents resource sharing across teams, and makes cross-team +collaboration impossible. It also pushes the multi-tenancy problem to the +infrastructure layer without solving it. In practice, even within a single +team, individual members typically have private per-user API keys for services +like Claude or Codex that they cannot share with teammates. This pushes +team-level deployments toward per-user gateways, compounding the operational +cost. The multi-player proposal mitigates this by giving each user their own +workspace on a shared gateway for credential isolation, while allowing teams +to share workspaces for collaboration where appropriate. + +### OPA/Rego for authorization + +Use a policy language like OPA/Rego for fine-grained authorization decisions +instead of role-based access control. + +This was considered but deferred. The current need is coarse-grained role-based +isolation, not attribute-based policy evaluation. OPA/Rego authorization could +be layered on top of the workspace and role model in a future RFC if +fine-grained policies are needed. + +## Prior art + +- **Kubernetes namespaces and RBAC.** The workspace model draws from Kubernetes + conventions: hard isolation boundaries, labels for soft grouping, and RBAC + with role bindings scoped to boundaries. The term "workspace" is intentionally + distinct from "Kubernetes namespace" to avoid conflation — an OpenShell + workspace may map to a Kubernetes namespace, but the concepts are independent. + +- **GitHub organizations and teams.** GitHub's model of organizations + (workspaces) with teams (label-based grouping) and per-repo role assignments + informed the separation between hard boundaries and soft grouping. + +- **AWS IAM.** AWS's account-level isolation with IAM roles and policies within + accounts informed the quota and credential scoping model. The lesson is that + hard account boundaries with delegated administration scales better than + flat permission models. + +## Appendix: End-to-End Workspace Lifecycle + +This walkthrough traces a workspace from creation through sandbox teardown, +showing the authorization check at each step. + +**1. Platform Admin creates a workspace.** + + CreateWorkspace { name: "team-ml" } + → global_role = "platform_admin" → pass + → persist Workspace { name: "team-ml" } + +**2. Platform Admin adds a Workspace Admin.** + + AddWorkspaceMember { workspace: "team-ml", subject: "bob@corp.example.com", role: WORKSPACE_ADMIN } + → global_role = "platform_admin" → bypass membership check → pass + → persist membership ("team-ml", "bob@corp.example.com", Admin) + +**3. Workspace Admin adds a User.** + + AddWorkspaceMember { workspace: "team-ml", subject: "alice@corp.example.com", role: USER } + → authorize_workspace("team-ml", WorkspaceRole::Admin) + → lookup ("team-ml", "bob@corp.example.com") → Admin → pass + → validate: Workspace Admins cannot assign the Admin role → USER is allowed + → persist membership ("team-ml", "alice@corp.example.com", User) + +**4. Workspace Admin adds a provider.** + + CreateProvider { workspace: "team-ml", name: "claude-key", type: "claude", credentials: {...} } + → authorize_workspace("team-ml", WorkspaceRole::Admin) → pass + → persist Provider { workspace: "team-ml", name: "claude-key" } + +**5. User creates a sandbox.** + + CreateSandbox { workspace: "team-ml", name: "my-sandbox", providers: ["claude-key"] } + → authorize_workspace("team-ml", WorkspaceRole::User) + → lookup ("team-ml", "alice@corp.example.com") → User → pass + → validate provider "claude-key" exists in "team-ml" → yes + → resolve effective policy: gateway default + provider profiles for ["claude-key"] + → persist Sandbox { workspace: "team-ml", name: "my-sandbox" } + → dispatch to driver (K8s managed → namespace "openshell-prod-team-ml") + +**6. User deletes the sandbox.** + + DeleteSandbox { workspace: "team-ml", name: "my-sandbox" } + → authorize_workspace("team-ml", WorkspaceRole::User) → pass + → drain and delete sandbox + → dispatch delete to driver + +Membership records are stored in the durable object store as a +`(workspace, principal_subject) → role` mapping, separate from the Workspace +resource itself. This follows the Kubernetes pattern where RoleBindings are +independent resources, not properties of the Namespace object. + +### Sandbox Supervisor Lifecycle + +The sandbox supervisor uses a separate authentication path from user roles. +This walkthrough continues from step 5 above, showing how the supervisor +bootstraps and operates scoped to a single sandbox. + +**7. Gateway mints a sandbox JWT at creation time.** + + CreateSandbox → persist sandbox (uuid-a) → mint JWT: + JWT { sub: "spiffe://openshell/sandbox/uuid-a", sandbox_id: "uuid-a" } + → token injected into container/pod via compute driver + +**8. Supervisor connects to the gateway.** + + ConnectSupervisor (bidirectional stream) + Auth: Bearer + → SandboxJwtAuthenticator → Principal::Sandbox { sandbox_id: "uuid-a" } + → router: is_sandbox_callable("ConnectSupervisor") → yes + → supervisor sends SupervisorHello { sandbox_id: "uuid-a" } + → ensure_sandbox_principal_scope: JWT sandbox_id == hello sandbox_id → pass + → register session, send SessionAccepted, notify driver: sandbox ready + +**9. Supervisor fetches provider credentials.** + + GetSandboxProviderEnvironment { sandbox_id: "uuid-a" } + → enforce_sandbox_scope: JWT sandbox_id == request sandbox_id → pass + → gateway resolves providers for sandbox uuid-a (workspace-internal lookup) + → return { ANTHROPIC_API_KEY: "sk-...", ... } + +**10. Cross-sandbox and cross-principal access is rejected.** + + Supervisor-A → GetSandboxProviderEnvironment { sandbox_id: "uuid-b" } + → enforce_sandbox_scope: "uuid-a" != "uuid-b" → PERMISSION_DENIED + + Supervisor-A → ListSandboxes { workspace: "team-ml" } + → router: is_sandbox_callable("ListSandboxes") → false → PERMISSION_DENIED + +The supervisor does not know or reference its workspace. The gateway resolves +workspace context internally when looking up providers or policies. The +supervisor's authorization surface is a single sandbox UUID, enforced by the +JWT subject and per-handler scope guards — entirely independent of the +workspace role model. +