diff --git a/Cargo.lock b/Cargo.lock index b412467610..2c1d0b8a4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4195,12 +4195,12 @@ dependencies = [ "openshell-prover", "openshell-providers", "openshell-router", - "openshell-server-macros", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "petname", "pin-project-lite", "prost", + "prost-reflect", "prost-types", "rand 0.9.4", "rcgen", diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 7b68487ea1..5fde55949b 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2026,11 +2026,17 @@ pub async fn sandbox_create( } None => None, }; - let providers_v2_enabled = gateway_providers_v2_enabled(&mut client).await?; + let inferred_provider = inferred_provider_type(command); + let providers_v2_enabled = + if inferred_provider.is_some() && auto_providers_override != Some(false) { + gateway_providers_v2_enabled(&mut client).await? + } else { + false + }; let inferred_types: Vec = if providers_v2_enabled { Vec::new() } else { - inferred_provider_type(command).into_iter().collect() + inferred_provider.into_iter().collect() }; let configured_providers = ensure_required_providers( &mut client, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index fda3d529f3..6339816b2f 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -31,7 +31,7 @@ use std::collections::HashMap; use std::fs; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use tempfile::TempDir; use tokio::net::TcpListener; @@ -48,6 +48,7 @@ struct SandboxState { vm_slow_progress_before_ready: Arc, vm_log_churn_before_ready: Arc, global_settings: Arc>>, + gateway_config_requests: Arc, } #[derive(Clone, Default)] @@ -182,6 +183,9 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { + self.state + .gateway_config_requests + .fetch_add(1, Ordering::SeqCst); Ok(Response::new(GetGatewayConfigResponse { settings: self.state.global_settings.lock().await.clone(), settings_revision: 1, @@ -1208,6 +1212,40 @@ async fn sandbox_create_keeps_command_sessions_by_default() { ); } +#[tokio::test] +async fn sandbox_create_without_inferred_provider_skips_gateway_config() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("no-provider-config"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed without reading gateway config"); + + assert_eq!( + server + .openshell + .state + .gateway_config_requests + .load(Ordering::SeqCst), + 0, + "commands without an inferred provider must not require global gateway settings" + ); +} + #[tokio::test] async fn sandbox_create_sends_cpu_and_memory_limits_only() { let server = run_server().await; diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 4c4f289ed6..989377b2a3 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -26,7 +26,6 @@ openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } -openshell-server-macros = { path = "../openshell-server-macros" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } @@ -40,6 +39,7 @@ tokio = { workspace = true } # gRPC tonic = { workspace = true, features = ["channel", "tls-native-roots"] } prost = { workspace = true } +prost-reflect = { workspace = true } prost-types = { workspace = true } # HTTP server diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 1c04b09766..0922ef22fd 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -13,7 +13,7 @@ //! authorization is a gateway concern. use super::identity::Identity; -use super::method_authz::{self, Role}; +use super::{descriptor_authz, method_authz}; use tonic::Status; use tracing::debug; @@ -62,14 +62,16 @@ impl AuthzPolicy { /// Returns `Ok(())` if authorized, `Err(PERMISSION_DENIED)` if not. /// When both role names are empty, all authenticated callers are authorized /// (authentication-only mode for providers like GitHub). + /// + /// Methods annotated with `global_role` (e.g. `"platform_admin"`) require + /// the `admin_role` OIDC claim. Methods annotated with only + /// `workspace_role` require the `user_role` OIDC claim — the handler + /// enforces workspace-level role via `authorize_workspace()`. #[allow(clippy::result_large_err)] pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { - let required = match method_authz::required_role(method) { - Some(Role::Admin) => &self.admin_role, - // Default to user role for unknown methods, matching the - // pre-annotation behavior. The exhaustiveness test ensures - // every real RPC has an explicit declaration. - Some(Role::User) | None => &self.user_role, + let required = match descriptor_authz::lookup(method) { + Some(entry) if entry.global_role.is_some() => &self.admin_role, + _ => &self.user_role, }; // Empty role name = skip role check for this level (auth-only mode). @@ -180,25 +182,51 @@ mod tests { } #[test] - fn user_cannot_access_admin_methods() { + fn user_blocked_for_platform_admin_methods() { let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); assert!( policy - .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") + .is_err() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/GetGatewayInfo") .is_err() ); } #[test] - fn admin_can_access_admin_methods() { - let id = identity_with_roles(&["openshell-admin", "openshell-user"]); + fn user_passes_middleware_for_workspace_admin_methods() { + let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); assert!( policy .check(&id, "/openshell.v1.OpenShell/CreateProvider") .is_ok() ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/DeleteProvider") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/AddWorkspaceMember") + .is_ok() + ); + } + + #[test] + fn admin_can_access_platform_admin_methods() { + let id = identity_with_roles(&["openshell-admin", "openshell-user"]); + let policy = default_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") + .is_ok() + ); } #[test] @@ -253,7 +281,7 @@ mod tests { }; assert!( policy - .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") .is_ok() ); assert!( @@ -408,7 +436,7 @@ mod tests { } #[test] - fn provider_refresh_methods_require_provider_scopes_and_admin_for_writes() { + fn provider_refresh_methods_require_provider_scopes() { let policy = scoped_policy(); let reader = identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]); assert!( @@ -417,17 +445,26 @@ mod tests { .is_ok() ); - let writer_without_admin = - identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]); - let err = policy - .check( - &writer_without_admin, - "/openshell.v1.OpenShell/ConfigureProviderRefresh", - ) - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - assert!(err.message().contains("openshell-admin")); + // Workspace-admin methods now pass middleware with user role + correct scope. + // Handler enforces workspace membership. + let writer = identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]); + assert!( + policy + .check(&writer, "/openshell.v1.OpenShell/ConfigureProviderRefresh") + .is_ok() + ); + assert!( + policy + .check(&writer, "/openshell.v1.OpenShell/RotateProviderCredential") + .is_ok() + ); + assert!( + policy + .check(&writer, "/openshell.v1.OpenShell/DeleteProviderRefresh") + .is_ok() + ); + // Wrong scope still rejected. let admin_without_scope = identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]); let err = policy @@ -438,16 +475,6 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("provider:write")); - - let admin_writer = - identity_with_roles_and_scopes(&["openshell-admin"], &["provider:write"]); - for method in [ - "/openshell.v1.OpenShell/ConfigureProviderRefresh", - "/openshell.v1.OpenShell/RotateProviderCredential", - "/openshell.v1.OpenShell/DeleteProviderRefresh", - ] { - assert!(policy.check(&admin_writer, method).is_ok(), "{method}"); - } } #[test] @@ -493,10 +520,16 @@ mod tests { .check(&id, "/openshell.v1.OpenShell/GetProvider") .is_ok() ); - // admin methods still denied by role check + // Workspace-admin methods pass middleware with user role. assert!( policy .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_ok() + ); + // Platform-admin methods still denied by role check. + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") .is_err() ); } @@ -507,7 +540,7 @@ mod tests { let policy = scoped_policy(); assert!( policy - .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") .is_ok() ); assert!( diff --git a/crates/openshell-server/src/auth/descriptor_authz.rs b/crates/openshell-server/src/auth/descriptor_authz.rs new file mode 100644 index 0000000000..3edbc28710 --- /dev/null +++ b/crates/openshell-server/src/auth/descriptor_authz.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Descriptor-pool-based authorization metadata. +//! +//! Reads per-method `(openshell.options.v1.authorization)` annotations from +//! the compiled `FileDescriptorSet` and builds an auth lookup table keyed by +//! gRPC path. The `method_authz` module re-exports the public API from here. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use prost_reflect::{DescriptorPool, Value}; + +use super::method_authz::{AuthMode, Role}; + +const AUTHORIZATION_EXTENSION: &str = "openshell.options.v1.authorization"; + +/// Gateway-served protobuf packages. +const GATEWAY_PACKAGES: &[&str] = &["openshell.v1", "openshell.inference.v1"]; + +/// Per-method authorization entry decoded from proto annotations. +#[derive(Debug, Clone)] +pub struct DescriptorAuthEntry { + pub auth_mode: AuthMode, + pub scope: Option, + pub workspace_role: Option, + pub global_role: Option, +} + +impl DescriptorAuthEntry { + /// Map the Phase 2 role fields back to the flat `Role` enum used by the + /// existing middleware. `global_role: "platform_admin"` and + /// `workspace_role: "admin"` both map to `Role::Admin`; + /// `workspace_role: "user"` maps to `Role::User`. + pub fn effective_role(&self) -> Option { + self.global_role.as_deref().map_or_else( + || { + self.workspace_role.as_deref().and_then(|wr| match wr { + "admin" => Some(Role::Admin), + "user" => Some(Role::User), + _ => None, + }) + }, + |gr| match gr { + "platform_admin" => Some(Role::Admin), + _ => None, + }, + ) + } + + /// Returns `true` when this method uses workspace-level authorization + /// (checked by the handler) rather than global-level (checked by + /// middleware). + #[allow(dead_code)] + pub fn is_workspace_scoped(&self) -> bool { + self.workspace_role.is_some() + } +} + +/// Auth table built from the descriptor pool. +pub struct DescriptorAuthTable { + entries: HashMap, +} + +static TABLE: LazyLock = LazyLock::new(|| { + DescriptorAuthTable::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + .expect("failed to build auth table from descriptor set") +}); + +impl DescriptorAuthTable { + fn from_descriptor_set(bytes: &[u8]) -> Result { + let pool = + DescriptorPool::decode(bytes).map_err(|e| format!("decode descriptor pool: {e}"))?; + + let auth_ext = pool + .get_extension_by_name(AUTHORIZATION_EXTENSION) + .ok_or_else(|| { + format!("extension {AUTHORIZATION_EXTENSION} not found in descriptor pool") + })?; + + let mut entries = HashMap::new(); + + for service in pool.services() { + let file = service.parent_file(); + let package = file.package_name(); + if !GATEWAY_PACKAGES.contains(&package) { + continue; + } + + for method in service.methods() { + let path = format!("/{}.{}/{}", package, service.name(), method.name()); + let options = method.options(); + + if !options.has_extension(&auth_ext) { + return Err(format!("method {path} missing (authorization) option")); + } + + let auth_value = options.get_extension(&auth_ext); + let Value::Message(ref auth_msg) = *auth_value else { + return Err(format!( + "method {path}: authorization option is not a message" + )); + }; + + let auth_mode_str = string_field(auth_msg, "auth_mode"); + let workspace_role_str = string_field(auth_msg, "workspace_role"); + let global_role_str = string_field(auth_msg, "global_role"); + let scope_str = string_field(auth_msg, "scope"); + + let auth_mode = match auth_mode_str.as_str() { + "unauthenticated" => AuthMode::Unauthenticated, + "sandbox" => AuthMode::Sandbox, + "bearer" => AuthMode::Bearer, + "dual" => AuthMode::Dual, + other => { + return Err(format!("method {path}: unknown auth_mode '{other}'")); + } + }; + + let workspace_role = non_empty(workspace_role_str); + let global_role = non_empty(global_role_str); + let scope = non_empty(scope_str); + + entries.insert( + path, + DescriptorAuthEntry { + auth_mode, + scope, + workspace_role, + global_role, + }, + ); + } + } + + Ok(Self { entries }) + } +} + +fn string_field(msg: &prost_reflect::DynamicMessage, name: &str) -> String { + msg.get_field_by_name(name) + .and_then(|v| match &*v { + Value::String(s) => Some(s.clone()), + _ => None, + }) + .unwrap_or_default() +} + +fn non_empty(s: String) -> Option { + if s.is_empty() { None } else { Some(s) } +} + +/// Look up descriptor-pool auth metadata for a gRPC method path. +pub fn lookup(method: &str) -> Option<&'static DescriptorAuthEntry> { + TABLE.entries.get(method) +} + +/// Iterator over all registered method paths. +#[cfg(test)] +pub fn all_paths() -> impl Iterator { + TABLE.entries.keys().map(String::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_proto_rpc_has_authorization_option() { + let pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor set"); + + let mut missing: Vec = Vec::new(); + + for service in pool.services() { + let file = service.parent_file(); + let package = file.package_name(); + if !GATEWAY_PACKAGES.contains(&package) { + continue; + } + for method in service.methods() { + let path = format!("/{}.{}/{}", package, service.name(), method.name()); + if lookup(&path).is_none() { + missing.push(path); + } + } + } + + assert!( + missing.is_empty(), + "RPC methods missing (authorization) option: {missing:?}" + ); + } + + #[test] + fn no_duplicate_paths() { + let paths: Vec<&str> = all_paths().collect(); + let mut seen = Vec::new(); + for path in &paths { + assert!( + !seen.contains(path), + "duplicate path in descriptor auth table: {path}" + ); + seen.push(path); + } + } +} diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs index ec8dc5bca3..ef90a74b6e 100644 --- a/crates/openshell-server/src/auth/method_authz.rs +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -3,28 +3,11 @@ //! Aggregated auth metadata for every gRPC method. //! -//! The per-method tables are generated by `#[rpc_authz]` (see -//! `openshell-server-macros`) and live next to each service's `impl` -//! block. This module merges them and exposes the lookup functions -//! consumed by `authz.rs` (role/scope), `oidc.rs` (unauthenticated -//! check), and `sandbox_methods.rs` (sandbox principal allowlist). +//! Delegates to the descriptor-pool-based auth table in `descriptor_authz`, +//! which reads per-method `(openshell.options.v1.authorization)` annotations +//! from the compiled `FileDescriptorSet`. -/// Per-method auth metadata emitted by `#[rpc_authz]`. -/// -/// Built at compile time and looked up at request-dispatch time. -#[derive(Debug, Clone, Copy)] -pub struct MethodAuth { - /// Canonical gRPC path (`/package.Service/Method`). - pub path: &'static str, - /// Authentication mode for the method. - pub mode: AuthMode, - /// Required OIDC scope on the Bearer path. `None` when the method - /// is `unauthenticated` or `sandbox`-only. - pub scope: Option<&'static str>, - /// Required role on the Bearer path. `None` when the method is - /// `unauthenticated` or `sandbox`-only. - pub role: Option, -} +pub use super::descriptor_authz::DescriptorAuthEntry; /// How a gRPC method is authenticated. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,46 +27,36 @@ pub enum AuthMode { /// Coarse role mapping. Maps to the configured `admin_role` / /// `user_role` names at runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] pub enum Role { Admin, User, } -/// All per-service auth tables in one flat list. -/// -/// Add a new service by appending its module's `AUTH_METADATA` const here. -/// The constant name is fixed by `#[rpc_authz]`; service disambiguation -/// comes from the module path. -const SERVICES: &[&[MethodAuth]] = &[crate::grpc::AUTH_METADATA, crate::inference::AUTH_METADATA]; - /// Find the auth metadata for `method`, if any. #[must_use] -pub fn lookup(method: &str) -> Option<&'static MethodAuth> { - for table in SERVICES { - if let Some(entry) = table.iter().find(|m| m.path == method) { - return Some(entry); - } - } - None +pub fn lookup(method: &str) -> Option<&'static DescriptorAuthEntry> { + super::descriptor_authz::lookup(method) } -/// All registered RPC paths across every service. Used by tests. +/// All registered RPC paths across every service. #[cfg(test)] pub fn all_paths() -> impl Iterator { - SERVICES.iter().flat_map(|s| s.iter()).map(|m| m.path) + super::descriptor_authz::all_paths() } /// Required Bearer scope for the method, or `None` if scopes don't /// apply (`unauthenticated`, `sandbox`). #[must_use] -pub fn required_scope(method: &str) -> Option<&'static str> { - lookup(method).and_then(|m| m.scope) +pub fn required_scope(method: &str) -> Option<&str> { + lookup(method).and_then(|e| e.scope.as_deref()) } /// Required role for the method on the Bearer path. #[must_use] +#[allow(dead_code)] pub fn required_role(method: &str) -> Option { - lookup(method).and_then(|m| m.role) + lookup(method).and_then(DescriptorAuthEntry::effective_role) } /// `true` if the method bypasses authentication entirely. @@ -94,7 +67,7 @@ pub fn required_role(method: &str) -> Option { #[must_use] pub fn is_unauthenticated(method: &str) -> bool { matches!( - lookup(method).map(|m| m.mode), + lookup(method).map(|m| m.auth_mode), Some(AuthMode::Unauthenticated) ) } @@ -104,7 +77,7 @@ pub fn is_unauthenticated(method: &str) -> bool { #[must_use] pub fn is_sandbox_callable(method: &str) -> bool { matches!( - lookup(method).map(|m| m.mode), + lookup(method).map(|m| m.auth_mode), Some(AuthMode::Sandbox | AuthMode::Dual) ) } @@ -112,13 +85,14 @@ pub fn is_sandbox_callable(method: &str) -> bool { /// `true` if the method is callable by a `Principal::User` (`bearer` or /// `dual` auth mode). /// -/// Unknown methods return `true` so [`AuthzPolicy::check`] still gets a -/// chance to evaluate role/scope and apply the `openshell:all` fallback — -/// the exhaustiveness test prevents this branch from ever firing for real -/// RPCs, but it remains as defense-in-depth. +/// Unknown methods return `true` so [`super::authz::AuthzPolicy::check`] +/// still gets a chance to evaluate role/scope and apply the +/// `openshell:all` fallback — the exhaustiveness test prevents this +/// branch from ever firing for real RPCs, but it remains as +/// defense-in-depth. #[must_use] pub fn is_user_callable(method: &str) -> bool { - match lookup(method).map(|m| m.mode) { + match lookup(method).map(|m| m.auth_mode) { Some(AuthMode::Sandbox | AuthMode::Unauthenticated) => false, Some(AuthMode::Bearer | AuthMode::Dual) | None => true, } @@ -127,88 +101,22 @@ pub fn is_user_callable(method: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use prost::Message; - use prost_types::FileDescriptorSet; - /// Every RPC declared in any proto under `proto/` must have an - /// `#[rpc_auth(...)]` annotation on its handler. This catches: - /// - new RPCs added to a proto but no annotation on the handler - /// - typo'd method names in annotations (path mismatch) - /// - services that were never given an `#[rpc_authz]` impl + /// Every RPC path in the descriptor pool is resolvable through this + /// delegation layer. #[test] fn every_proto_rpc_has_an_annotation() { - let set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET) - .expect("decode descriptor set"); - - let mut missing: Vec = Vec::new(); - - for file in &set.file { - let package = file.package.as_deref().unwrap_or(""); - // Only check services the gateway actually serves. Skip the - // compute-driver, sandbox supervisor, and test protos because - // those are not surfaced through the gateway's gRPC server. - if package != "openshell.v1" && package != "openshell.inference.v1" { - continue; - } - for svc in &file.service { - let svc_name = svc.name.as_deref().unwrap_or(""); - for method in &svc.method { - let method_name = method.name.as_deref().unwrap_or(""); - let path = format!("/{package}.{svc_name}/{method_name}"); - if lookup(&path).is_none() { - missing.push(path); - } - } - } - } - - assert!( - missing.is_empty(), - "RPC methods missing #[rpc_auth] annotation: {missing:?}" - ); - } - - /// Every annotated path must exist as a real RPC in some proto. This - /// catches stale annotations after an RPC is removed or renamed. - #[test] - fn every_annotated_path_matches_a_real_rpc() { - let set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET) - .expect("decode descriptor set"); - - let mut proto_paths: Vec = Vec::new(); - for file in &set.file { - let package = file.package.as_deref().unwrap_or(""); - for svc in &file.service { - let svc_name = svc.name.as_deref().unwrap_or(""); - for method in &svc.method { - let method_name = method.name.as_deref().unwrap_or(""); - proto_paths.push(format!("/{package}.{svc_name}/{method_name}")); - } - } - } - - let mut stale: Vec<&'static str> = Vec::new(); for path in all_paths() { - if !proto_paths.iter().any(|p| p == path) { - stale.push(path); - } + assert!(lookup(path).is_some(), "lookup failed for path: {path}"); } - - assert!( - stale.is_empty(), - "annotated paths that don't match any real proto RPC: {stale:?}" - ); } - /// Sanity check: no path appears in more than one service table. + /// No path appears more than once. #[test] fn no_duplicate_paths_across_services() { - let mut seen: Vec<&'static str> = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); for path in all_paths() { - assert!( - !seen.contains(&path), - "duplicate path across tables: {path}" - ); + assert!(!seen.contains(&path), "duplicate path: {path}"); seen.push(path); } } diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index cbf3b94d91..c26fac08ad 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -10,6 +10,7 @@ pub mod authenticator; pub mod authz; +pub mod descriptor_authz; pub mod guard; mod http; pub mod identity; @@ -19,5 +20,6 @@ pub mod oidc; pub mod principal; pub mod sandbox_jwt; pub mod sandbox_methods; +pub mod workspace_authz; pub use http::router; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index bf5490f2af..cbe83ff060 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -29,7 +29,7 @@ use tracing::{debug, info, warn}; /// /// These are structural bypasses for gRPC infrastructure that doesn't map to a /// single RPC method. Per-method bypasses (e.g. `Health`) are declared at the -/// handler with `#[rpc_auth(auth = "unauthenticated")]`. +/// handler with `auth_mode: "unauthenticated"` in the proto annotation. const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; /// Returns `true` if the method needs no authentication at all. diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index b90841d85a..a74b1280ce 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -8,7 +8,7 @@ //! principals for every method outside this supervisor-to-gateway allowlist; //! handlers still perform same-sandbox checks on request bodies. //! -//! The allowlist is derived from per-handler `#[rpc_auth(...)]` annotations: +//! The allowlist is derived from proto-level `(authorization)` annotations: //! a method is callable by a sandbox principal when its declared auth mode is //! `sandbox` or `dual`. diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs new file mode 100644 index 0000000000..bc69f6de88 --- /dev/null +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace-scoped authorization. +//! +//! Enforces membership and role requirements for workspace-scoped operations. +//! Called by handlers after middleware authentication — the middleware validates +//! auth mode + scope + global role; this module validates workspace membership +//! and workspace-level role. + +use super::principal::Principal; +use openshell_core::proto::WorkspaceRole as ProtoWorkspaceRole; +use tonic::Status; + +use crate::persistence::Store; + +/// Minimum workspace-level role required by a handler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MinWorkspaceRole { + /// Workspace User — the caller must be at least a member. + User, + /// Workspace Admin — the caller must be an admin member. + Admin, +} + +/// Result of a successful workspace authorization check. +#[derive(Debug)] +pub struct AuthorizedWorkspace { + /// Resolved workspace name (empty string normalized to `"default"`). + #[allow(dead_code)] + pub workspace: String, + /// How the caller was authorized. + pub grant: AuthGrant, +} + +/// How a caller was granted workspace access. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthGrant { + /// Caller holds the platform admin OIDC role — bypasses membership. + PlatformAdmin, + /// Caller is a workspace member with the given role. + Member(ProtoWorkspaceRole), + /// Caller is a sandbox principal — scoped by JWT, no membership check. + Sandbox, +} + +/// Authorize a workspace-scoped operation for a user principal. +/// +/// Checks workspace membership and role. Platform admins (callers whose +/// OIDC roles include `admin_role`) bypass the membership check entirely. +/// +/// When `admin_role` is empty (auth-only mode / OIDC not configured), every +/// authenticated user is treated as a platform admin — matching the existing +/// behavior where empty role names skip RBAC. +#[allow(clippy::result_large_err)] +pub async fn authorize_workspace( + store: &Store, + admin_role: &str, + principal: &Principal, + workspace: &str, + min_role: MinWorkspaceRole, +) -> Result { + let workspace = normalize_workspace(workspace); + + match principal { + Principal::User(user) => { + if is_platform_admin(&user.identity.roles, admin_role) { + return Ok(AuthorizedWorkspace { + workspace, + grant: AuthGrant::PlatformAdmin, + }); + } + + let member = store + .get_message_by_name::( + &workspace, + &user.identity.subject, + ) + .await + .map_err(|e| Status::internal(format!("membership lookup failed: {e}")))?; + + let Some(member) = member else { + return Err(Status::permission_denied(format!( + "not a member of workspace '{workspace}'" + ))); + }; + + let member_role = ProtoWorkspaceRole::try_from(member.role) + .unwrap_or(ProtoWorkspaceRole::Unspecified); + + if !role_satisfies(member_role, min_role) { + return Err(Status::permission_denied(format!( + "workspace role '{min_role:?}' required in workspace '{workspace}'" + ))); + } + + Ok(AuthorizedWorkspace { + workspace, + grant: AuthGrant::Member(member_role), + }) + } + Principal::Sandbox(_) => Ok(AuthorizedWorkspace { + workspace, + grant: AuthGrant::Sandbox, + }), + Principal::Anonymous => Err(Status::unauthenticated("authentication required")), + } +} + +/// Authorize a data-plane operation where the workspace is resolved from the +/// sandbox record rather than the request message. +/// +/// Used by `ExecSandbox`, `ForwardTcp`, `WatchSandbox`, `CreateSshSession` — these +/// RPCs identify a sandbox by name/ID and the handler resolves the workspace +/// from the sandbox record. +#[allow(clippy::result_large_err)] +pub async fn authorize_sandbox_workspace( + store: &Store, + admin_role: &str, + principal: &Principal, + sandbox_workspace: &str, + min_role: MinWorkspaceRole, +) -> Result { + let result = + authorize_workspace(store, admin_role, principal, sandbox_workspace, min_role).await?; + Ok(result.grant) +} + +/// Require Platform Admin status. Used for cross-workspace operations like +/// `list_*` with `all_workspaces: true`. +#[allow(clippy::result_large_err)] +pub fn require_platform_admin(admin_role: &str, principal: &Principal) -> Result<(), Status> { + match principal { + Principal::User(user) if is_platform_admin(&user.identity.roles, admin_role) => Ok(()), + Principal::User(_) => Err(Status::permission_denied( + "platform admin role required for cross-workspace operations", + )), + Principal::Sandbox(_) => Err(Status::permission_denied( + "sandbox principals cannot perform cross-workspace operations", + )), + Principal::Anonymous => Err(Status::unauthenticated("authentication required")), + } +} + +/// Check whether the caller's OIDC roles include the platform admin role. +/// +/// When `admin_role` is empty (OIDC not configured), returns `true` — +/// matching the existing behavior where empty role names skip RBAC. +pub fn is_platform_admin_principal(identity_roles: &[String], admin_role: &str) -> bool { + is_platform_admin(identity_roles, admin_role) +} + +fn is_platform_admin(identity_roles: &[String], admin_role: &str) -> bool { + admin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role) +} + +/// Check whether `member_role` satisfies the `min_role` requirement. +fn role_satisfies(member_role: ProtoWorkspaceRole, min_role: MinWorkspaceRole) -> bool { + match min_role { + MinWorkspaceRole::User => matches!( + member_role, + ProtoWorkspaceRole::User | ProtoWorkspaceRole::Admin + ), + MinWorkspaceRole::Admin => member_role == ProtoWorkspaceRole::Admin, + } +} + +fn normalize_workspace(workspace: &str) -> String { + if workspace.is_empty() { + "default".to_string() + } else { + workspace.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::identity::{Identity, IdentityProvider}; + use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{WorkspaceMember, WorkspaceRole as ProtoWorkspaceRole}; + use std::collections::HashMap; + + async fn test_store() -> Store { + crate::persistence::test_store().await + } + + fn user_principal(subject: &str, roles: &[&str]) -> Principal { + Principal::User(UserPrincipal { + identity: Identity { + subject: subject.to_string(), + display_name: None, + roles: roles.iter().map(|r| (*r).to_string()).collect(), + scopes: vec![], + provider: IdentityProvider::Oidc, + }, + }) + } + + fn sandbox_principal() -> Principal { + Principal::Sandbox(SandboxPrincipal { + sandbox_id: "sandbox-a".to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: Some("openshell".to_string()), + }) + } + + async fn add_member(store: &Store, workspace: &str, subject: &str, role: ProtoWorkspaceRole) { + let member = WorkspaceMember { + metadata: Some(ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: subject.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + annotations: HashMap::new(), + resource_version: 0, + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + principal_subject: subject.to_string(), + role: role.into(), + }; + store.put_message(&member).await.expect("add member"); + } + + #[tokio::test] + async fn platform_admin_bypasses_membership_check() { + let store = test_store().await; + let principal = user_principal("admin-user", &["openshell-admin", "openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "any-workspace", + MinWorkspaceRole::Admin, + ) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().grant, AuthGrant::PlatformAdmin); + } + + #[tokio::test] + async fn workspace_admin_member_passes_admin_check() { + let store = test_store().await; + add_member(&store, "default", "user-a", ProtoWorkspaceRole::Admin).await; + let principal = user_principal("user-a", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::Admin, + ) + .await; + assert!(result.is_ok()); + assert_eq!( + result.unwrap().grant, + AuthGrant::Member(ProtoWorkspaceRole::Admin) + ); + } + + #[tokio::test] + async fn workspace_user_member_passes_user_check() { + let store = test_store().await; + add_member(&store, "default", "user-b", ProtoWorkspaceRole::User).await; + let principal = user_principal("user-b", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_ok()); + assert_eq!( + result.unwrap().grant, + AuthGrant::Member(ProtoWorkspaceRole::User) + ); + } + + #[tokio::test] + async fn workspace_user_member_rejected_for_admin_check() { + let store = test_store().await; + add_member(&store, "default", "user-c", ProtoWorkspaceRole::User).await; + let principal = user_principal("user-c", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::Admin, + ) + .await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("workspace role")); + } + + #[tokio::test] + async fn non_member_rejected() { + let store = test_store().await; + let principal = user_principal("stranger", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("not a member")); + } + + #[tokio::test] + async fn anonymous_principal_rejected() { + let store = test_store().await; + let result = authorize_workspace( + &store, + "openshell-admin", + &Principal::Anonymous, + "default", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::Unauthenticated); + } + + #[tokio::test] + async fn sandbox_principal_passes_through() { + let store = test_store().await; + let principal = sandbox_principal(); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().grant, AuthGrant::Sandbox); + } + + #[tokio::test] + async fn empty_workspace_normalizes_to_default() { + let store = test_store().await; + add_member(&store, "default", "user-d", ProtoWorkspaceRole::User).await; + let principal = user_principal("user-d", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().workspace, "default"); + } + + #[tokio::test] + async fn auth_disabled_empty_admin_role_is_platform_admin() { + let store = test_store().await; + let principal = user_principal("any-user", &[]); + let result = + authorize_workspace(&store, "", &principal, "default", MinWorkspaceRole::Admin).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().grant, AuthGrant::PlatformAdmin); + } + + #[tokio::test] + async fn workspace_admin_member_passes_user_check() { + let store = test_store().await; + add_member(&store, "default", "admin-member", ProtoWorkspaceRole::Admin).await; + let principal = user_principal("admin-member", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::User, + ) + .await; + assert!(result.is_ok()); + assert_eq!( + result.unwrap().grant, + AuthGrant::Member(ProtoWorkspaceRole::Admin) + ); + } +} diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index ecd96ea90d..0ca7720470 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -58,7 +58,6 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use crate::ServerState; -use openshell_server_macros::rpc_authz; // --------------------------------------------------------------------------- // Public re-exports @@ -101,6 +100,21 @@ pub fn persistence_error_to_status( } } +/// Extract the `Principal` from request extensions, or return `INTERNAL`. +/// +/// The middleware layer always inserts a `Principal` for authenticated methods, +/// so a missing principal indicates an internal wiring error rather than a +/// caller fault. +pub fn extract_principal( + request: &Request, +) -> Result { + request + .extensions() + .get::() + .cloned() + .ok_or_else(|| Status::internal("missing principal")) +} + // --------------------------------------------------------------------------- // Field-level size limits (shared across submodules) // --------------------------------------------------------------------------- @@ -202,10 +216,8 @@ impl OpenShellService { // Trait impl — thin delegation to submodules // --------------------------------------------------------------------------- -#[rpc_authz(service = "openshell.v1.OpenShell")] #[tonic::async_trait] impl OpenShell for OpenShellService { - #[rpc_auth(auth = "unauthenticated")] async fn health( &self, _request: Request, @@ -216,7 +228,6 @@ impl OpenShell for OpenShellService { })) } - #[rpc_auth(auth = "bearer", scope = "config:read", role = "admin")] async fn get_gateway_info( &self, _request: Request, @@ -244,7 +255,6 @@ impl OpenShell for OpenShellService { // --- Sandbox lifecycle --- - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_sandbox( &self, request: Request, @@ -254,10 +264,6 @@ impl OpenShell for OpenShellService { type WatchSandboxStream = ReceiverStream>; - // TODO(phase2): data-plane RPCs do not carry a workspace field. Add - // workspace verification to confirm the sandbox belongs to the caller's - // workspace before proxying. - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn watch_sandbox( &self, request: Request, @@ -265,7 +271,6 @@ impl OpenShell for OpenShellService { sandbox::handle_watch_sandbox(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox( &self, request: Request, @@ -273,9 +278,6 @@ impl OpenShell for OpenShellService { sandbox::handle_get_sandbox(&self.state, request).await } - // TODO(phase2): all_workspaces flag is currently accessible to any - // authenticated user. Restrict to Platform Admin role in Phase 2. - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandboxes( &self, request: Request, @@ -283,7 +285,6 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandboxes(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandbox_providers( &self, request: Request, @@ -291,7 +292,6 @@ impl OpenShell for OpenShellService { sandbox::handle_list_sandbox_providers(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn attach_sandbox_provider( &self, request: Request, @@ -299,7 +299,6 @@ impl OpenShell for OpenShellService { sandbox::handle_attach_sandbox_provider(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn detach_sandbox_provider( &self, request: Request, @@ -307,7 +306,6 @@ impl OpenShell for OpenShellService { sandbox::handle_detach_sandbox_provider(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn delete_sandbox( &self, request: Request, @@ -319,8 +317,6 @@ impl OpenShell for OpenShellService { type ExecSandboxStream = ReceiverStream>; - // TODO(phase2): no workspace field — see watch_sandbox comment. - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox( &self, request: Request, @@ -331,8 +327,6 @@ impl OpenShell for OpenShellService { type ForwardTcpStream = Pin> + Send + 'static>>; - // TODO(phase2): no workspace field — see watch_sandbox comment. - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn forward_tcp( &self, request: Request>, @@ -342,7 +336,6 @@ impl OpenShell for OpenShellService { type ExecSandboxInteractiveStream = ReceiverStream>; - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox_interactive( &self, request: Request>, @@ -352,8 +345,6 @@ impl OpenShell for OpenShellService { // --- SSH sessions --- - // TODO(phase2): no workspace field — see watch_sandbox comment. - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_ssh_session( &self, request: Request, @@ -361,7 +352,6 @@ impl OpenShell for OpenShellService { sandbox::handle_create_ssh_session(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn expose_service( &self, request: Request, @@ -369,7 +359,6 @@ impl OpenShell for OpenShellService { service::handle_expose_service(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_service( &self, request: Request, @@ -377,9 +366,6 @@ impl OpenShell for OpenShellService { service::handle_get_service(&self.state, request).await } - // TODO(phase2): all_workspaces flag is currently accessible to any - // authenticated user. Restrict to Platform Admin role in Phase 2. - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_services( &self, request: Request, @@ -387,7 +373,6 @@ impl OpenShell for OpenShellService { service::handle_list_services(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn delete_service( &self, request: Request, @@ -395,7 +380,6 @@ impl OpenShell for OpenShellService { service::handle_delete_service(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn revoke_ssh_session( &self, request: Request, @@ -405,7 +389,6 @@ impl OpenShell for OpenShellService { // --- Providers --- - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn create_provider( &self, request: Request, @@ -413,7 +396,6 @@ impl OpenShell for OpenShellService { provider::handle_create_provider(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider( &self, request: Request, @@ -421,9 +403,6 @@ impl OpenShell for OpenShellService { provider::handle_get_provider(&self.state, request).await } - // TODO(phase2): all_workspaces flag is currently accessible to any - // authenticated user. Restrict to Platform Admin role in Phase 2. - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_providers( &self, request: Request, @@ -431,7 +410,6 @@ impl OpenShell for OpenShellService { provider::handle_list_providers(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_provider_profiles( &self, request: Request, @@ -439,7 +417,6 @@ impl OpenShell for OpenShellService { provider::handle_list_provider_profiles(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider_profile( &self, request: Request, @@ -447,7 +424,6 @@ impl OpenShell for OpenShellService { provider::handle_get_provider_profile(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn import_provider_profiles( &self, request: Request, @@ -455,7 +431,6 @@ impl OpenShell for OpenShellService { provider::handle_import_provider_profiles(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn update_provider_profiles( &self, request: Request, @@ -463,7 +438,6 @@ impl OpenShell for OpenShellService { provider::handle_update_provider_profiles(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn lint_provider_profiles( &self, request: Request, @@ -471,7 +445,6 @@ impl OpenShell for OpenShellService { provider::handle_lint_provider_profiles(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn update_provider( &self, request: Request, @@ -479,7 +452,6 @@ impl OpenShell for OpenShellService { provider::handle_update_provider(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn get_provider_refresh_status( &self, request: Request, @@ -487,7 +459,6 @@ impl OpenShell for OpenShellService { provider::handle_get_provider_refresh_status(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn configure_provider_refresh( &self, request: Request, @@ -495,7 +466,6 @@ impl OpenShell for OpenShellService { provider::handle_configure_provider_refresh(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn rotate_provider_credential( &self, request: Request, @@ -503,7 +473,6 @@ impl OpenShell for OpenShellService { provider::handle_rotate_provider_credential(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider_refresh( &self, request: Request, @@ -511,7 +480,6 @@ impl OpenShell for OpenShellService { provider::handle_delete_provider_refresh(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider( &self, request: Request, @@ -519,7 +487,6 @@ impl OpenShell for OpenShellService { provider::handle_delete_provider(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "provider:write", role = "admin")] async fn delete_provider_profile( &self, request: Request, @@ -529,7 +496,6 @@ impl OpenShell for OpenShellService { // --- Config / Policy --- - #[rpc_auth(auth = "dual", scope = "config:read", role = "user")] async fn get_sandbox_config( &self, request: Request, @@ -537,7 +503,6 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_config(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:read", role = "user")] async fn get_gateway_config( &self, request: Request, @@ -545,7 +510,6 @@ impl OpenShell for OpenShellService { policy::handle_get_gateway_config(&self.state, request).await } - #[rpc_auth(auth = "sandbox")] async fn get_sandbox_provider_environment( &self, request: Request, @@ -553,7 +517,6 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } - #[rpc_auth(auth = "dual", scope = "config:write", role = "admin")] async fn update_config( &self, request: Request, @@ -561,7 +524,6 @@ impl OpenShell for OpenShellService { policy::handle_update_config(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox_policy_status( &self, request: Request, @@ -569,7 +531,6 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_policy_status(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandbox_policies( &self, request: Request, @@ -577,7 +538,6 @@ impl OpenShell for OpenShellService { policy::handle_list_sandbox_policies(&self.state, request).await } - #[rpc_auth(auth = "sandbox")] async fn report_policy_status( &self, request: Request, @@ -587,7 +547,6 @@ impl OpenShell for OpenShellService { // --- Sandbox logs --- - #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn get_sandbox_logs( &self, request: Request, @@ -595,7 +554,6 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_logs(&self.state, request).await } - #[rpc_auth(auth = "sandbox")] async fn push_sandbox_logs( &self, request: Request>, @@ -605,7 +563,6 @@ impl OpenShell for OpenShellService { // --- Draft policy recommendations --- - #[rpc_auth(auth = "sandbox")] async fn submit_policy_analysis( &self, request: Request, @@ -613,7 +570,6 @@ impl OpenShell for OpenShellService { policy::handle_submit_policy_analysis(&self.state, request).await } - #[rpc_auth(auth = "dual", scope = "config:read", role = "user")] async fn get_draft_policy( &self, request: Request, @@ -621,7 +577,6 @@ impl OpenShell for OpenShellService { policy::handle_get_draft_policy(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn approve_draft_chunk( &self, request: Request, @@ -629,7 +584,6 @@ impl OpenShell for OpenShellService { policy::handle_approve_draft_chunk(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn reject_draft_chunk( &self, request: Request, @@ -637,7 +591,6 @@ impl OpenShell for OpenShellService { policy::handle_reject_draft_chunk(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn approve_all_draft_chunks( &self, request: Request, @@ -645,7 +598,6 @@ impl OpenShell for OpenShellService { policy::handle_approve_all_draft_chunks(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn edit_draft_chunk( &self, request: Request, @@ -653,7 +605,6 @@ impl OpenShell for OpenShellService { policy::handle_edit_draft_chunk(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn undo_draft_chunk( &self, request: Request, @@ -661,7 +612,6 @@ impl OpenShell for OpenShellService { policy::handle_undo_draft_chunk(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:write", role = "admin")] async fn clear_draft_chunks( &self, request: Request, @@ -669,7 +619,6 @@ impl OpenShell for OpenShellService { policy::handle_clear_draft_chunks(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "config:read", role = "user")] async fn get_draft_history( &self, request: Request, @@ -679,7 +628,6 @@ impl OpenShell for OpenShellService { // --- Sandbox identity --- - #[rpc_auth(auth = "sandbox")] async fn issue_sandbox_token( &self, request: Request, @@ -687,7 +635,6 @@ impl OpenShell for OpenShellService { auth_rpc::handle_issue_sandbox_token(&self.state, request).await } - #[rpc_auth(auth = "sandbox")] async fn refresh_sandbox_token( &self, request: Request, @@ -700,7 +647,6 @@ impl OpenShell for OpenShellService { type ConnectSupervisorStream = Pin> + Send + 'static>>; - #[rpc_auth(auth = "sandbox")] async fn connect_supervisor( &self, request: Request>, @@ -711,7 +657,6 @@ impl OpenShell for OpenShellService { type RelayStreamStream = Pin> + Send + 'static>>; - #[rpc_auth(auth = "sandbox")] async fn relay_stream( &self, request: Request>, @@ -722,7 +667,6 @@ impl OpenShell for OpenShellService { // --- Workspace management --- - #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] async fn create_workspace( &self, request: Request, @@ -730,7 +674,6 @@ impl OpenShell for OpenShellService { workspace::handle_create_workspace(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] async fn get_workspace( &self, request: Request, @@ -738,7 +681,6 @@ impl OpenShell for OpenShellService { workspace::handle_get_workspace(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] async fn list_workspaces( &self, request: Request, @@ -746,7 +688,6 @@ impl OpenShell for OpenShellService { workspace::handle_list_workspaces(&self.state, request).await } - #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] async fn delete_workspace( &self, request: Request, @@ -754,7 +695,6 @@ impl OpenShell for OpenShellService { 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, @@ -762,7 +702,6 @@ impl OpenShell for OpenShellService { 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, @@ -770,7 +709,6 @@ impl OpenShell for OpenShellService { 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, @@ -789,6 +727,8 @@ pub mod test_support { use std::sync::Arc; use crate::ServerState; + use crate::auth::identity::{Identity, IdentityProvider}; + use crate::auth::principal::{Principal, UserPrincipal}; use crate::compute::new_test_runtime; use crate::persistence::Store; use crate::sandbox_index::SandboxIndex; @@ -796,6 +736,27 @@ pub mod test_support { use crate::supervisor_session::SupervisorSessionRegistry; use crate::tracing_bus::TracingLogBus; use openshell_core::Config; + use tonic::Request; + + /// Wrap a proto message in a `Request` with a dev principal injected. + /// + /// The dev principal matches the unauthenticated dev user: subject + /// `"dev-user"`, roles `["openshell-admin", "openshell-user"]`. + /// Since `test_server_state()` has an empty `admin_role`, `authorize_workspace()` + /// treats every authenticated user as Platform Admin. + pub fn authed_request(inner: T) -> Request { + let mut req = Request::new(inner); + req.extensions_mut().insert(Principal::User(UserPrincipal { + identity: Identity { + subject: "dev-user".to_string(), + display_name: None, + roles: vec!["openshell-admin".to_string(), "openshell-user".to_string()], + scopes: vec![], + provider: IdentityProvider::Oidc, + }, + })); + req + } /// Build an in-memory `ServerState` for unit tests. pub async fn test_server_state() -> Arc { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 12f563d17b..cd88d8df37 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,6 +12,9 @@ use crate::ServerState; use crate::auth::principal::Principal; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, authorize_sandbox_workspace, authorize_workspace, +}; use crate::persistence::{ DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, }; @@ -1219,6 +1222,7 @@ pub(super) async fn handle_get_sandbox_config( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let sandbox_id = request.get_ref().sandbox_id.clone(); crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); @@ -1230,6 +1234,14 @@ pub(super) async fn handle_get_sandbox_config( .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(); + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let sandbox_provider_names = sandbox .spec .as_ref() @@ -1636,6 +1648,15 @@ async fn handle_update_config_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + if let Some(ref p) = principal { + let min_role = if sandbox_caller { + MinWorkspaceRole::User + } else { + MinWorkspaceRole::Admin + }; + authorize_sandbox_workspace(&state.store, &state.admin_role, p, &workspace, min_role) + .await?; + } if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( @@ -2238,13 +2259,23 @@ pub(super) async fn handle_get_sandbox_policy_status( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = if req.global { String::new() } else { - super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) + let ws = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? - .name + .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &ws, + MinWorkspaceRole::User, + ) + .await?; + ws }; let (policy_id, active_version) = if req.global { @@ -2296,13 +2327,23 @@ pub(super) async fn handle_list_sandbox_policies( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = if req.global { String::new() } else { - super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) + let ws = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? - .name + .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &ws, + MinWorkspaceRole::User, + ) + .await?; + ws }; let policy_id = if req.global { @@ -2419,20 +2460,29 @@ pub(super) async fn handle_report_policy_status( // Sandbox logs handlers // --------------------------------------------------------------------------- -#[allow(clippy::unused_async)] // Must be async to match the trait signature pub(super) async fn handle_get_sandbox_logs( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); - // TODO(phase2): workspace is resolved but not used for authorization. - // Verify the sandbox belongs to this workspace before returning logs. - let _workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) - .await? - .name; if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } + let sandbox = state + .store + .get_message::(&req.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; let lines = if req.lines == 0 { 2000 } else { req.lines }; let tail = state.tracing_log_bus.tail(&req.sandbox_id, lines as usize); @@ -2839,6 +2889,14 @@ pub(super) async fn handle_get_draft_policy( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2907,10 +2965,19 @@ async fn handle_approve_draft_chunk_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3010,10 +3077,19 @@ async fn handle_reject_draft_chunk_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3111,10 +3187,19 @@ async fn handle_approve_all_draft_chunks_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3235,10 +3320,19 @@ pub(super) async fn handle_edit_draft_chunk( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3302,10 +3396,19 @@ async fn handle_undo_draft_chunk_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3390,10 +3493,19 @@ pub(super) async fn handle_clear_draft_chunks( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -3429,10 +3541,19 @@ pub(super) async fn handle_get_draft_history( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -4403,7 +4524,7 @@ mod tests { use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; - use crate::grpc::test_support::test_server_state; + use crate::grpc::test_support::{authed_request, test_server_state}; use crate::persistence::test_store; use std::collections::HashMap; use std::sync::Arc; @@ -4530,6 +4651,58 @@ mod tests { assert!(!is_sandbox_caller(&req)); } + #[tokio::test] + async fn get_sandbox_logs_authorizes_persisted_sandbox_workspace() { + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{WorkspaceMember, WorkspaceRole}; + + let mut state = test_server_state().await; + Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string(); + let sandbox = Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-b-id".to_string(), + name: "sandbox-b".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "workspace-b".to_string(), + deletion_timestamp_ms: 0, + }), + ..Sandbox::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let member = WorkspaceMember { + metadata: Some(ObjectMeta { + id: "member-a-id".to_string(), + name: "test-user".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + principal_subject: "test-user".to_string(), + role: WorkspaceRole::User.into(), + }; + state.store.put_message(&member).await.unwrap(); + + let error = handle_get_sandbox_logs( + &state, + with_user(Request::new(GetSandboxLogsRequest { + sandbox_id: "sandbox-b-id".to_string(), + workspace: "default".to_string(), + ..GetSandboxLogsRequest::default() + })), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::PermissionDenied); + } + #[test] fn merge_operation_validation_rejects_reserved_provider_add_rule_name() { let err = validate_merge_operations_for_server(&[PolicyMergeOp::AddRule { @@ -6258,7 +6431,7 @@ mod tests { handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -6309,7 +6482,7 @@ mod tests { enable_providers_v2(&state).await; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { source: "custom-api.yaml".to_string(), profile: Some(ProviderProfile { @@ -6429,7 +6602,7 @@ mod tests { handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, @@ -6772,7 +6945,7 @@ mod tests { let approve = handle_approve_draft_chunk( &state, - Request::new(ApproveDraftChunkRequest { + authed_request(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -6786,7 +6959,7 @@ mod tests { let history_after_approve = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { + authed_request(GetDraftHistoryRequest { name: sandbox_name.clone(), workspace: "default".to_string(), }), @@ -6801,7 +6974,7 @@ mod tests { let policies_after_approve = handle_list_sandbox_policies( &state, - Request::new(ListSandboxPoliciesRequest { + authed_request(ListSandboxPoliciesRequest { name: sandbox_name.clone(), limit: 10, offset: 0, @@ -6817,7 +6990,7 @@ mod tests { let undo = handle_undo_draft_chunk( &state, - Request::new(UndoDraftChunkRequest { + authed_request(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -6845,7 +7018,7 @@ mod tests { let history_after_undo = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { + authed_request(GetDraftHistoryRequest { name: sandbox_name.clone(), workspace: "default".to_string(), }), @@ -6858,7 +7031,7 @@ mod tests { let policies_after_undo = handle_list_sandbox_policies( &state, - Request::new(ListSandboxPoliciesRequest { + authed_request(ListSandboxPoliciesRequest { name: sandbox_name.clone(), limit: 10, offset: 0, @@ -6875,7 +7048,7 @@ mod tests { let cleared = handle_clear_draft_chunks( &state, - Request::new(ClearDraftChunksRequest { + authed_request(ClearDraftChunksRequest { name: sandbox_name.clone(), workspace: "default".to_string(), }), @@ -6900,7 +7073,7 @@ mod tests { let history_after_clear = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { + authed_request(GetDraftHistoryRequest { name: sandbox_name, workspace: "default".to_string(), }), @@ -6975,7 +7148,7 @@ mod tests { let guidance = "scope to docs/ paths only, not all repo contents"; handle_reject_draft_chunk( &state, - Request::new(RejectDraftChunkRequest { + authed_request(RejectDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), @@ -8944,7 +9117,7 @@ mod tests { // exact path the smoke test exercises end-to-end. handle_reject_draft_chunk( &state, - Request::new(RejectDraftChunkRequest { + authed_request(RejectDraftChunkRequest { name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), @@ -9400,7 +9573,7 @@ mod tests { handle_reject_draft_chunk( &state, - Request::new(RejectDraftChunkRequest { + authed_request(RejectDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), @@ -9412,7 +9585,7 @@ mod tests { handle_approve_draft_chunk( &state, - Request::new(ApproveDraftChunkRequest { + authed_request(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -9423,7 +9596,7 @@ mod tests { handle_undo_draft_chunk( &state, - Request::new(UndoDraftChunkRequest { + authed_request(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -9559,7 +9732,7 @@ mod tests { let approve_err = handle_approve_draft_chunk( &state, - Request::new(ApproveDraftChunkRequest { + authed_request(ApproveDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -9571,7 +9744,7 @@ mod tests { let reject_err = handle_reject_draft_chunk( &state, - Request::new(RejectDraftChunkRequest { + authed_request(RejectDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), @@ -9584,7 +9757,7 @@ mod tests { let edit_err = handle_edit_draft_chunk( &state, - Request::new(EditDraftChunkRequest { + authed_request(EditDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), @@ -9597,7 +9770,7 @@ mod tests { handle_approve_draft_chunk( &state, - Request::new(ApproveDraftChunkRequest { + authed_request(ApproveDraftChunkRequest { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), workspace: "default".to_string(), @@ -9608,7 +9781,7 @@ mod tests { let undo_err = handle_undo_draft_chunk( &state, - Request::new(UndoDraftChunkRequest { + authed_request(UndoDraftChunkRequest { name: other_name, chunk_id, workspace: "default".to_string(), @@ -11348,7 +11521,7 @@ mod tests { let response = handle_update_config( &state, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -11435,7 +11608,7 @@ mod tests { let response = handle_update_config( &state, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "annotated-backfill".to_string(), policy: Some(ProtoSandboxPolicy::default()), setting_key: String::new(), @@ -11511,7 +11684,7 @@ mod tests { let response = handle_update_config( &state, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "same-hash".to_string(), policy: Some(policy), annotations: HashMap::from([( @@ -11853,7 +12026,7 @@ mod tests { let response = handle_update_config( &state, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "preserve-backfill".to_string(), policy: Some(ProtoSandboxPolicy::default()), expected_resource_version: current_version, @@ -12127,7 +12300,7 @@ mod tests { let err = handle_update_config( &state, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -12226,7 +12399,7 @@ mod tests { let handle = tokio::spawn(async move { handle_update_config( &state_clone, - Request::new(UpdateConfigRequest { + authed_request(UpdateConfigRequest { name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e8f67d0fa8..6ffd51a0c7 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1325,14 +1325,25 @@ use openshell_providers::{ use std::sync::Arc; use tonic::{Request, Response}; +use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace, require_platform_admin}; + pub(super) async fn handle_create_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .ensure_active()?; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1378,10 +1389,19 @@ pub(super) async fn handle_get_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let provider = get_provider_record(state.store.as_ref(), &workspace, &req.name).await?; Ok(Response::new(ProviderResponse { @@ -1393,6 +1413,7 @@ pub(super) async fn handle_list_providers( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); if request.all_workspaces && !request.workspace.is_empty() { return Err(Status::invalid_argument( @@ -1402,6 +1423,7 @@ pub(super) async fn handle_list_providers( let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); let providers = if request.all_workspaces { + require_platform_admin(&state.admin_role, &principal)?; let all: Vec = state .store .list_all_messages(limit, request.offset) @@ -1413,6 +1435,14 @@ pub(super) async fn handle_list_providers( super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; list_provider_records(state.store.as_ref(), &workspace, limit, request.offset).await? }; @@ -1428,11 +1458,22 @@ pub(super) async fn handle_list_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) .await? .name; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; let catalog = state @@ -1454,11 +1495,22 @@ pub(super) async fn handle_get_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace) .await? .name; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; + } let id = req.id; let id = normalize_profile_id_request(&id)?; let catalog = state @@ -1478,11 +1530,22 @@ pub(super) async fn handle_import_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) .await? .ensure_active()?; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .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; @@ -1561,11 +1624,22 @@ pub(super) async fn handle_update_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) .await? .ensure_active()?; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .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); @@ -1685,11 +1759,22 @@ pub(super) async fn handle_lint_provider_profiles( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) .await? .name; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; + } let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let catalog = state @@ -1712,11 +1797,22 @@ pub(super) async fn handle_delete_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace) .await? .name; + if !workspace.is_empty() { + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; + } let id = req.id; let id = normalize_profile_id_request(&id)?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; @@ -2322,10 +2418,19 @@ pub(super) async fn handle_update_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -2371,10 +2476,19 @@ pub(super) async fn handle_get_provider_refresh_status( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if request.provider.trim().is_empty() { return Err(Status::invalid_argument("provider is required")); } @@ -2415,10 +2529,19 @@ pub(super) async fn handle_configure_provider_refresh( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2704,10 +2827,19 @@ pub(super) async fn handle_rotate_provider_credential( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2763,10 +2895,19 @@ pub(super) async fn handle_delete_provider_refresh( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2831,10 +2972,19 @@ pub(super) async fn handle_delete_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .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; @@ -2913,7 +3063,7 @@ fn telemetry_provider_profile(provider_type: &str) -> TelemetryProviderProfile { #[cfg(test)] mod tests { use super::*; - use crate::grpc::test_support::test_server_state; + use crate::grpc::test_support::{authed_request, test_server_state}; use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ @@ -3080,7 +3230,7 @@ mod tests { }]; handle_import_provider_profiles( state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -3218,7 +3368,7 @@ mod tests { }]; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "grant-new.yaml".to_string(), @@ -3246,7 +3396,7 @@ mod tests { let task = tokio::spawn(async move { handle_import_provider_profiles( &task_state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), @@ -3299,7 +3449,7 @@ mod tests { }]; let response = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(updated_profile.clone()), source: "custom-api.yaml".to_string(), @@ -3344,7 +3494,7 @@ mod tests { let built_in = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -3366,7 +3516,7 @@ mod tests { let missing = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("missing-custom")), source: "missing-custom.yaml".to_string(), @@ -3398,7 +3548,7 @@ mod tests { let missing_version = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -3421,7 +3571,7 @@ mod tests { stale_profile.resource_version = 99; let stale_error = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(stale_profile), source: "custom-api.yaml".to_string(), @@ -3470,7 +3620,7 @@ mod tests { edited_payload.display_name = "Wrong overwrite".to_string(); let response = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(edited_payload), source: "profile-a.yaml".to_string(), @@ -3553,7 +3703,7 @@ mod tests { }]; let response = handle_update_provider_profiles( &state, - Request::new(UpdateProviderProfilesRequest { + authed_request(UpdateProviderProfilesRequest { profile: Some(ProviderProfileImportItem { profile: Some(profile), source: "grant-updated.yaml".to_string(), @@ -3675,7 +3825,7 @@ mod tests { profile.credentials = vec![refreshable_credential("access_token", credential_key)]; handle_import_provider_profiles( state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -3737,7 +3887,7 @@ mod tests { let state = test_server_state().await; let response = handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 100, offset: 0, workspace: "default".to_string(), @@ -3787,7 +3937,7 @@ mod tests { let state = test_server_state().await; let github = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "github".to_string(), workspace: "default".to_string(), }), @@ -3805,7 +3955,7 @@ mod tests { let generic_err = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "generic".to_string(), workspace: "default".to_string(), }), @@ -3820,7 +3970,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -3837,7 +3987,7 @@ mod tests { let listed = handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 100, offset: 0, workspace: "default".to_string(), @@ -3855,7 +4005,7 @@ mod tests { let fetched = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "custom-api".to_string(), workspace: "default".to_string(), }), @@ -3873,7 +4023,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -3901,7 +4051,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), @@ -3918,7 +4068,7 @@ mod tests { let imported = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "custom-llm".to_string(), workspace: "default".to_string(), }), @@ -3936,7 +4086,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile(" alex-api ")), @@ -3974,7 +4124,7 @@ mod tests { let state = test_server_state().await; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), @@ -3987,7 +4137,7 @@ mod tests { let fetched = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: " Alex-API ".to_string(), workspace: "default".to_string(), }), @@ -4001,7 +4151,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, - Request::new(DeleteProviderProfileRequest { + authed_request(DeleteProviderProfileRequest { id: " Alex-API ".to_string(), workspace: "default".to_string(), }), @@ -4017,7 +4167,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile("bulk-one")), @@ -4050,7 +4200,7 @@ mod tests { for id in ["bulk-one", "bulk-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: id.to_string(), workspace: "default".to_string(), }), @@ -4067,7 +4217,7 @@ mod tests { let state = test_server_state().await; let response = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "advanced-api".to_string(), @@ -4115,7 +4265,7 @@ mod tests { let fetched = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "advanced-api".to_string(), workspace: "default".to_string(), }), @@ -4146,7 +4296,7 @@ mod tests { let state = test_server_state().await; let response = handle_lint_provider_profiles( &state, - Request::new(LintProviderProfilesRequest { + authed_request(LintProviderProfilesRequest { profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile("lint-one")), @@ -4178,7 +4328,7 @@ mod tests { for id in ["lint-one", "lint-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: id.to_string(), workspace: "default".to_string(), }), @@ -4195,7 +4345,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-lint")), source: "scoped-lint.yaml".to_string(), @@ -4208,7 +4358,7 @@ mod tests { let conflict = handle_lint_provider_profiles( &state, - Request::new(LintProviderProfilesRequest { + authed_request(LintProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-lint")), source: "scoped-lint.yaml".to_string(), @@ -4242,7 +4392,7 @@ mod tests { let no_conflict = handle_lint_provider_profiles( &state, - Request::new(LintProviderProfilesRequest { + authed_request(LintProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-lint")), source: "scoped-lint.yaml".to_string(), @@ -4267,7 +4417,7 @@ mod tests { let state = test_server_state().await; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -4280,7 +4430,7 @@ mod tests { let builtin_err = handle_delete_provider_profile( &state, - Request::new(DeleteProviderProfileRequest { + authed_request(DeleteProviderProfileRequest { id: "github".to_string(), workspace: "default".to_string(), }), @@ -4320,7 +4470,7 @@ mod tests { let in_use_err = handle_delete_provider_profile( &state, - Request::new(DeleteProviderProfileRequest { + authed_request(DeleteProviderProfileRequest { id: "custom-api".to_string(), workspace: "default".to_string(), }), @@ -4366,7 +4516,7 @@ mod tests { let expires_at_ms = crate::persistence::current_time_ms() + 60_000; let response = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -4389,7 +4539,7 @@ mod tests { let status = handle_get_provider_refresh_status( &state, - Request::new(GetProviderRefreshStatusRequest { + authed_request(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), workspace: "default".to_string(), @@ -4416,7 +4566,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, - Request::new(DeleteProviderRefreshRequest { + authed_request(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), workspace: "default".to_string(), @@ -4429,7 +4579,7 @@ mod tests { let status_after_delete = handle_get_provider_refresh_status( &state, - Request::new(GetProviderRefreshStatusRequest { + authed_request(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), workspace: "default".to_string(), @@ -4496,7 +4646,7 @@ mod tests { handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -4574,7 +4724,7 @@ mod tests { handle_delete_provider_refresh( &state, - Request::new(DeleteProviderRefreshRequest { + authed_request(DeleteProviderRefreshRequest { provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), workspace: "default".to_string(), @@ -4629,7 +4779,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "vertex-sa".to_string(), credential_key: "GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, @@ -4699,7 +4849,7 @@ mod tests { let refresh_expires_at_ms = crate::persistence::current_time_ms() + 60_000; handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -4746,7 +4896,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, - Request::new(DeleteProviderRefreshRequest { + authed_request(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), workspace: "default".to_string(), @@ -4811,7 +4961,7 @@ mod tests { let refresh_expires_at_ms = crate::persistence::current_time_ms() + 60_000; handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -4860,7 +5010,7 @@ mod tests { handle_delete_provider_refresh( &state, - Request::new(DeleteProviderRefreshRequest { + authed_request(DeleteProviderRefreshRequest { provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), workspace: "default".to_string(), @@ -5027,7 +5177,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "refreshing-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -5106,7 +5256,7 @@ mod tests { handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "first-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -5125,7 +5275,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "second-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -5183,7 +5333,7 @@ mod tests { let endpoint_override = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -5208,7 +5358,7 @@ mod tests { let missing_material = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -5261,7 +5411,7 @@ mod tests { ] { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: strategy as i32, @@ -5292,7 +5442,7 @@ mod tests { let state = test_server_state().await; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -5305,7 +5455,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, - Request::new(DeleteProviderProfileRequest { + authed_request(DeleteProviderProfileRequest { id: "custom-api".to_string(), workspace: "default".to_string(), }), @@ -5317,7 +5467,7 @@ mod tests { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "custom-api".to_string(), workspace: "default".to_string(), }), @@ -5341,7 +5491,7 @@ mod tests { let task = tokio::spawn(async move { handle_delete_provider_profile( &task_state, - Request::new(DeleteProviderProfileRequest { + authed_request(DeleteProviderProfileRequest { id: "guarded-delete".to_string(), workspace: "default".to_string(), }), @@ -5702,7 +5852,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "delegated-refresh-api".to_string(), @@ -5791,7 +5941,7 @@ mod tests { ]; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), @@ -5833,7 +5983,7 @@ mod tests { ]; handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), @@ -7156,7 +7306,7 @@ mod tests { provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some(provider.clone()), workspace: "default".to_string(), }), @@ -7183,7 +7333,7 @@ mod tests { // Update should succeed let response = handle_update_provider( &state, - Request::new(UpdateProviderRequest { + authed_request(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), workspace: "default".to_string(), @@ -7226,7 +7376,7 @@ mod tests { provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some(provider.clone()), workspace: "default".to_string(), }), @@ -7253,7 +7403,7 @@ mod tests { // Update should fail with ABORTED let err = handle_update_provider( &state, - Request::new(UpdateProviderRequest { + authed_request(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), workspace: "default".to_string(), @@ -7295,7 +7445,7 @@ mod tests { provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some(provider.clone()), workspace: "default".to_string(), }), @@ -7325,7 +7475,7 @@ mod tests { let handle = tokio::spawn(async move { handle_update_provider( &state_clone, - Request::new(UpdateProviderRequest { + authed_request(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), workspace: "default".to_string(), @@ -7410,7 +7560,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "my-aws".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7481,7 +7631,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "my-aws-v2".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7553,7 +7703,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-endpoint-override".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7646,7 +7796,7 @@ mod tests { // silently falling back to the gateway's ambient identity. let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-partial-source".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7708,7 +7858,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-lone-session".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7780,7 +7930,7 @@ mod tests { handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-outputs".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7874,7 +8024,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "generic-no-profile".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7942,7 +8092,7 @@ mod tests { // profile declares no refresh on it, so STS cannot be pinned there. let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-wrong-key".to_string(), credential_key: "AWS_SECRET_ACCESS_KEY".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -8002,7 +8152,7 @@ mod tests { handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "aws-gate".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -8029,7 +8179,7 @@ mod tests { let err = handle_rotate_provider_credential( &state, - Request::new(RotateProviderCredentialRequest { + authed_request(RotateProviderCredentialRequest { provider: "aws-gate".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), workspace: "default".to_string(), @@ -8230,7 +8380,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: "new-aws-provider".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -8314,7 +8464,7 @@ mod tests { .unwrap(); let configure = |provider: &str| { - Request::new(ConfigureProviderRefreshRequest { + authed_request(ConfigureProviderRefreshRequest { provider: provider.to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -8514,7 +8664,7 @@ mod tests { let created_default = handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -8544,7 +8694,7 @@ mod tests { let created_beta = handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -8577,7 +8727,7 @@ mod tests { // Get in each workspace returns the correct provider. let got = handle_get_provider( &state, - Request::new(GetProviderRequest { + authed_request(GetProviderRequest { name: "shared-name".to_string(), workspace: "default".to_string(), }), @@ -8589,7 +8739,7 @@ mod tests { let got = handle_get_provider( &state, - Request::new(GetProviderRequest { + authed_request(GetProviderRequest { name: "shared-name".to_string(), workspace: "beta".to_string(), }), @@ -8602,7 +8752,7 @@ mod tests { // List is workspace-scoped. let listed = handle_list_providers( &state, - Request::new(ListProvidersRequest { + authed_request(ListProvidersRequest { limit: 100, offset: 0, workspace: "default".to_string(), @@ -8617,7 +8767,7 @@ mod tests { let listed = handle_list_providers( &state, - Request::new(ListProvidersRequest { + authed_request(ListProvidersRequest { limit: 100, offset: 0, workspace: "beta".to_string(), @@ -8633,7 +8783,7 @@ mod tests { // Delete in "default" does not affect "beta". let deleted = handle_delete_provider( &state, - Request::new(DeleteProviderRequest { + authed_request(DeleteProviderRequest { name: "shared-name".to_string(), workspace: "default".to_string(), }), @@ -8645,7 +8795,7 @@ mod tests { let listed = handle_list_providers( &state, - Request::new(ListProvidersRequest { + authed_request(ListProvidersRequest { limit: 100, offset: 0, workspace: "default".to_string(), @@ -8659,7 +8809,7 @@ mod tests { let got = handle_get_provider( &state, - Request::new(GetProviderRequest { + authed_request(GetProviderRequest { name: "shared-name".to_string(), workspace: "beta".to_string(), }), @@ -8673,7 +8823,7 @@ mod tests { // Re-create the "default" provider. handle_create_provider( &state, - Request::new(CreateProviderRequest { + authed_request(CreateProviderRequest { provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -8696,7 +8846,7 @@ mod tests { let listed = handle_list_providers( &state, - Request::new(ListProvidersRequest { + authed_request(ListProvidersRequest { limit: 100, offset: 0, workspace: String::new(), @@ -8711,7 +8861,7 @@ mod tests { // all_workspaces with non-empty workspace is rejected. let err = handle_list_providers( &state, - Request::new(ListProvidersRequest { + authed_request(ListProvidersRequest { limit: 100, offset: 0, workspace: "default".to_string(), @@ -8894,7 +9044,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-custom")), source: "ws-custom.yaml".to_string(), @@ -8946,7 +9096,7 @@ mod tests { async move { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -8972,7 +9122,7 @@ mod tests { async move { handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 200, offset: 0, workspace, @@ -9023,7 +9173,7 @@ mod tests { async move { handle_delete_provider_profile( &state, - Request::new(DeleteProviderProfileRequest { id, workspace }), + authed_request(DeleteProviderProfileRequest { id, workspace }), ) .await .unwrap() @@ -9046,7 +9196,7 @@ mod tests { async move { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -9083,7 +9233,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-api")), source: "scoped-api.yaml".to_string(), @@ -9096,7 +9246,7 @@ mod tests { let resp = handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 200, offset: 0, workspace: "default".to_string(), @@ -9128,7 +9278,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("platform-only")), source: "platform-only.yaml".to_string(), @@ -9141,7 +9291,7 @@ mod tests { let resp = handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 200, offset: 0, workspace: "default".to_string(), @@ -9165,7 +9315,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-target")), source: "shadow-target.yaml".to_string(), @@ -9180,7 +9330,7 @@ mod tests { ws_profile.display_name = "Workspace Shadow".to_string(); handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "shadow-target.yaml".to_string(), @@ -9193,7 +9343,7 @@ mod tests { let resp = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { + authed_request(GetProviderProfileRequest { id: "shadow-target".to_string(), workspace: "default".to_string(), }), @@ -9213,7 +9363,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -9226,7 +9376,7 @@ mod tests { let resp = handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -9255,7 +9405,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("global-only")), source: "global-only.yaml".to_string(), @@ -9268,7 +9418,7 @@ mod tests { handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-only")), source: "ws-only.yaml".to_string(), @@ -9281,7 +9431,7 @@ mod tests { let resp = handle_list_provider_profiles( &state, - Request::new(ListProviderProfilesRequest { + authed_request(ListProviderProfilesRequest { limit: 200, offset: 0, workspace: String::new(), @@ -9309,7 +9459,7 @@ mod tests { platform_profile.display_name = "Platform Version".to_string(); handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test.yaml".to_string(), @@ -9324,7 +9474,7 @@ mod tests { ws_profile.display_name = "Workspace Version".to_string(); handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test.yaml".to_string(), @@ -9358,7 +9508,7 @@ mod tests { platform_profile.display_name = "Platform Version".to_string(); handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test-ws.yaml".to_string(), @@ -9373,7 +9523,7 @@ mod tests { ws_profile.display_name = "Workspace Version".to_string(); handle_import_provider_profiles( &state, - Request::new(ImportProviderProfilesRequest { + authed_request(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test-ws.yaml".to_string(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 19238b1ea3..b41f7f7483 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,6 +10,9 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, authorize_sandbox_workspace, authorize_workspace, require_platform_admin, +}; use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::proto::{ @@ -128,6 +131,7 @@ async fn handle_create_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let spec = request .spec @@ -146,6 +150,14 @@ async fn handle_create_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .ensure_active()?; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let _sandbox_sync_guard = if spec.providers.is_empty() { None @@ -254,6 +266,7 @@ pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); @@ -261,6 +274,14 @@ pub(super) async fn handle_get_sandbox( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let sandbox = state .store @@ -278,6 +299,7 @@ pub(super) async fn handle_list_sandboxes( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); if request.all_workspaces && !request.workspace.is_empty() { return Err(Status::invalid_argument( @@ -287,6 +309,7 @@ pub(super) async fn handle_list_sandboxes( let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); let sandboxes: Vec = if request.all_workspaces { + require_platform_admin(&state.admin_role, &principal)?; if request.label_selector.is_empty() { state .store @@ -306,6 +329,14 @@ pub(super) async fn handle_list_sandboxes( super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if request.label_selector.is_empty() { state .store @@ -336,10 +367,19 @@ pub(super) async fn handle_list_sandbox_providers( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .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 })) @@ -349,10 +389,19 @@ pub(super) async fn handle_attach_sandbox_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .ensure_active()?; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -470,10 +519,19 @@ pub(super) async fn handle_detach_sandbox_provider( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let request = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -565,6 +623,7 @@ async fn handle_delete_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let name = req.name; if name.is_empty() { @@ -573,6 +632,14 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let sandbox_id = state .store @@ -648,17 +715,32 @@ fn dedupe_provider_names(provider_names: &mut Vec) { // Watch handler // --------------------------------------------------------------------------- -#[allow(clippy::unused_async)] // Must be async to match the trait signature pub(super) async fn handle_watch_sandbox( state: &Arc, request: Request, ) -> Result>>, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); if req.id.is_empty() { return Err(Status::invalid_argument("id is required")); } let sandbox_id = req.id.clone(); + let sandbox = state + .store + .get_message::(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; + let follow_status = req.follow_status; let follow_logs = req.follow_logs; let follow_events = req.follow_events; @@ -883,6 +965,7 @@ pub(super) async fn handle_exec_sandbox( ) -> Result>>, Status> { use openshell_core::ObjectId; + let principal = super::extract_principal(&request)?; let req = request.into_inner(); if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); @@ -903,6 +986,14 @@ pub(super) async fn handle_exec_sandbox( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -998,6 +1089,7 @@ pub(super) async fn handle_forward_tcp( >, Status, > { + let principal = super::extract_principal(&request)?; let mut inbound = request.into_inner(); let first = inbound .message() @@ -1017,6 +1109,14 @@ pub(super) async fn handle_forward_tcp( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -1328,6 +1428,7 @@ pub(super) async fn handle_exec_sandbox_interactive( ) -> Result>>, Status> { use openshell_core::ObjectId; + let principal = super::extract_principal(&request)?; let mut input_stream = request.into_inner(); let first_msg = input_stream @@ -1343,6 +1444,14 @@ pub(super) async fn handle_exec_sandbox_interactive( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -1407,6 +1516,7 @@ pub(super) async fn handle_create_ssh_session( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); @@ -1418,6 +1528,14 @@ pub(super) async fn handle_create_ssh_session( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -1496,6 +1614,7 @@ pub(super) async fn handle_revoke_ssh_session( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let token = request.into_inner().token; if token.is_empty() { return Err(Status::invalid_argument("token is required")); @@ -1510,6 +1629,14 @@ pub(super) async fn handle_revoke_ssh_session( let Some(mut session) = session else { return Ok(Response::new(RevokeSshSessionResponse { revoked: false })); }; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + &principal, + session.object_workspace(), + MinWorkspaceRole::User, + ) + .await?; let resource_version = session .metadata @@ -2111,7 +2238,7 @@ async fn run_exec_with_russh( #[cfg(test)] mod tests { use super::*; - use crate::grpc::test_support::test_server_state; + use crate::grpc::test_support::{authed_request, test_server_state}; use openshell_core::proto::datamodel::v1::ObjectMeta; // ---- shell_escape ---- @@ -2477,7 +2604,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -2521,7 +2648,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -2563,7 +2690,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -2588,7 +2715,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -2617,7 +2744,7 @@ mod tests { let response = handle_list_sandbox_providers( &state, - Request::new(ListSandboxProvidersRequest { + authed_request(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), workspace: String::new(), }), @@ -2645,7 +2772,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, @@ -2816,7 +2943,7 @@ mod tests { let err = handle_create_sandbox( &state, - Request::new(CreateSandboxRequest { + authed_request(CreateSandboxRequest { name: "collision".to_string(), spec: Some(openshell_core::proto::SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -2850,7 +2977,7 @@ mod tests { let err = handle_create_sandbox( &state, - Request::new(CreateSandboxRequest { + authed_request(CreateSandboxRequest { name: "reserved-policy-key".to_string(), spec: Some(openshell_core::proto::SandboxSpec { policy: Some(policy), @@ -2877,7 +3004,7 @@ mod tests { let response = handle_create_sandbox( &state, - Request::new(CreateSandboxRequest { + authed_request(CreateSandboxRequest { name: "annotated".to_string(), spec: Some(openshell_core::proto::SandboxSpec::default()), labels: HashMap::new(), @@ -2900,7 +3027,7 @@ mod tests { let fetched = handle_get_sandbox( &state, - Request::new(GetSandboxRequest { + authed_request(GetSandboxRequest { name: "annotated".to_string(), workspace: String::new(), }), @@ -2924,7 +3051,7 @@ mod tests { let state = test_server_state().await; let err = handle_create_sandbox( &state, - Request::new(CreateSandboxRequest { + authed_request(CreateSandboxRequest { name: "bad-label".to_string(), spec: Some(openshell_core::proto::SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), @@ -2953,7 +3080,7 @@ mod tests { let task = tokio::spawn(async move { handle_create_sandbox( &task_state, - Request::new(CreateSandboxRequest { + authed_request(CreateSandboxRequest { name: "guarded-create".to_string(), spec: Some(openshell_core::proto::SandboxSpec { providers: vec!["work-github".to_string()], @@ -3007,7 +3134,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, @@ -3054,7 +3181,7 @@ mod tests { // Attaching the 32nd provider should succeed let response = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, @@ -3109,7 +3236,7 @@ mod tests { // Attempting to attach the 33rd provider should fail let err = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, @@ -3156,7 +3283,7 @@ mod tests { // Should fail validation before attempting CAS let err = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -3183,7 +3310,7 @@ mod tests { let err = handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -3212,7 +3339,7 @@ mod tests { let handle1 = tokio::spawn(async move { handle_create_ssh_session( &state1, - Request::new(CreateSshSessionRequest { + authed_request(CreateSshSessionRequest { sandbox_id: "sandbox-work".to_string(), }), ) @@ -3223,7 +3350,7 @@ mod tests { let handle2 = tokio::spawn(async move { handle_create_ssh_session( &state2, - Request::new(CreateSshSessionRequest { + authed_request(CreateSshSessionRequest { sandbox_id: "sandbox-work".to_string(), }), ) @@ -3270,7 +3397,7 @@ mod tests { // Create a session first let response = handle_create_ssh_session( &state, - Request::new(CreateSshSessionRequest { + authed_request(CreateSshSessionRequest { sandbox_id: "sandbox-work".to_string(), }), ) @@ -3284,7 +3411,7 @@ mod tests { let handle1 = tokio::spawn(async move { handle_revoke_ssh_session( &state1, - Request::new(RevokeSshSessionRequest { token: token1 }), + authed_request(RevokeSshSessionRequest { token: token1 }), ) .await }); @@ -3294,7 +3421,7 @@ mod tests { let handle2 = tokio::spawn(async move { handle_revoke_ssh_session( &state2, - Request::new(RevokeSshSessionRequest { token: token2 }), + authed_request(RevokeSshSessionRequest { token: token2 }), ) .await }); @@ -3348,7 +3475,7 @@ mod tests { // Attach with correct expected_resource_version let response = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -3400,7 +3527,7 @@ mod tests { // Try to attach with a stale version (current_version - 1 would be 0, use 99 instead) let err = handle_attach_sandbox_provider( &state, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -3463,7 +3590,7 @@ mod tests { // Detach with correct expected_resource_version let response = handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -3515,7 +3642,7 @@ mod tests { // Try to detach with a stale version let err = handle_detach_sandbox_provider( &state, - Request::new(DetachSandboxProviderRequest { + authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -3596,7 +3723,7 @@ mod tests { let handle = tokio::spawn(async move { handle_attach_sandbox_provider( &state_clone, - Request::new(AttachSandboxProviderRequest { + authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, @@ -3682,7 +3809,7 @@ mod tests { // Get in "default" returns the default sandbox. let got = handle_get_sandbox( &state, - Request::new(GetSandboxRequest { + authed_request(GetSandboxRequest { name: "shared-name".to_string(), workspace: "default".to_string(), }), @@ -3695,7 +3822,7 @@ mod tests { // Get in "beta" returns the beta sandbox. let got = handle_get_sandbox( &state, - Request::new(GetSandboxRequest { + authed_request(GetSandboxRequest { name: "shared-name".to_string(), workspace: "beta".to_string(), }), @@ -3708,7 +3835,7 @@ mod tests { // List in "default" returns 1 sandbox. let listed = handle_list_sandboxes( &state, - Request::new(ListSandboxesRequest { + authed_request(ListSandboxesRequest { limit: 100, offset: 0, label_selector: String::new(), @@ -3725,7 +3852,7 @@ mod tests { // List in "beta" returns 1 sandbox. let listed = handle_list_sandboxes( &state, - Request::new(ListSandboxesRequest { + authed_request(ListSandboxesRequest { limit: 100, offset: 0, label_selector: String::new(), @@ -3749,7 +3876,7 @@ mod tests { // "default" now has 0 sandboxes. let listed = handle_list_sandboxes( &state, - Request::new(ListSandboxesRequest { + authed_request(ListSandboxesRequest { limit: 100, offset: 0, label_selector: String::new(), @@ -3765,7 +3892,7 @@ mod tests { // "beta" still has its sandbox. let got = handle_get_sandbox( &state, - Request::new(GetSandboxRequest { + authed_request(GetSandboxRequest { name: "shared-name".to_string(), workspace: "beta".to_string(), }), @@ -3791,7 +3918,7 @@ mod tests { .unwrap(); let listed = handle_list_sandboxes( &state, - Request::new(ListSandboxesRequest { + authed_request(ListSandboxesRequest { limit: 100, offset: 0, label_selector: String::new(), @@ -3807,7 +3934,7 @@ mod tests { // all_workspaces with non-empty workspace is rejected. let err = handle_list_sandboxes( &state, - Request::new(ListSandboxesRequest { + authed_request(ListSandboxesRequest { limit: 100, offset: 0, label_selector: String::new(), @@ -3831,7 +3958,7 @@ mod tests { let response = handle_create_ssh_session( &state, - Request::new(CreateSshSessionRequest { + authed_request(CreateSshSessionRequest { sandbox_id: "sandbox-ws-test".to_string(), }), ) @@ -3841,7 +3968,7 @@ mod tests { handle_revoke_ssh_session( &state, - Request::new(RevokeSshSessionRequest { + authed_request(RevokeSshSessionRequest { token: token.clone(), }), ) diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 7f042ae18d..bd56992b6a 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -15,6 +15,7 @@ use tonic::{Request, Response, Status}; use uuid::Uuid; use crate::ServerState; +use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace, require_platform_admin}; use crate::persistence::{ObjectType, WriteCondition}; use crate::service_routing; @@ -25,10 +26,19 @@ pub(super) async fn handle_expose_service( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .ensure_active()?; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .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) { @@ -135,10 +145,19 @@ pub(super) async fn handle_get_service( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; @@ -153,6 +172,7 @@ pub(super) async fn handle_list_services( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); if req.all_workspaces && !req.workspace.is_empty() { return Err(Status::invalid_argument( @@ -165,6 +185,7 @@ pub(super) async fn handle_list_services( let limit = super::clamp_limit(req.limit, 100, super::MAX_PAGE_SIZE); let endpoints: Vec = if req.all_workspaces { + require_platform_admin(&state.admin_role, &principal)?; if !req.sandbox.is_empty() { return Err(Status::invalid_argument( "sandbox filter is not supported with all_workspaces", @@ -175,6 +196,14 @@ pub(super) async fn handle_list_services( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; if req.sandbox.is_empty() { state .store @@ -206,10 +235,19 @@ pub(super) async fn handle_delete_service( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; @@ -316,7 +354,7 @@ fn is_dns_label(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::grpc::test_support::test_server_state; + use crate::grpc::test_support::{authed_request, test_server_state}; use openshell_core::proto::SandboxPhase; async fn seed_sandbox(state: &Arc, name: &str) { @@ -370,7 +408,7 @@ mod tests { let exposed = handle_expose_service( &state, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -385,7 +423,7 @@ mod tests { let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, @@ -404,7 +442,7 @@ mod tests { let fetched = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -417,7 +455,7 @@ mod tests { let deleted = handle_delete_service( &state, - Request::new(DeleteServiceRequest { + authed_request(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -430,7 +468,7 @@ mod tests { let err = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -442,7 +480,7 @@ mod tests { let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, @@ -466,7 +504,7 @@ mod tests { let handle1 = tokio::spawn(async move { handle_expose_service( &state1, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -481,7 +519,7 @@ mod tests { let handle2 = tokio::spawn(async move { handle_expose_service( &state2, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -507,7 +545,7 @@ mod tests { // Only one endpoint should exist let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, @@ -529,7 +567,7 @@ mod tests { // Create an initial endpoint handle_expose_service( &state, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 7070, @@ -545,7 +583,7 @@ mod tests { let handle1 = tokio::spawn(async move { handle_expose_service( &state1, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -560,7 +598,7 @@ mod tests { let handle2 = tokio::spawn(async move { handle_expose_service( &state2, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -585,7 +623,7 @@ mod tests { // The endpoint should have one of the new port values let fetched = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -644,7 +682,7 @@ mod tests { // Expose same service name on the same sandbox name in each workspace. handle_expose_service( &state, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -657,7 +695,7 @@ mod tests { handle_expose_service( &state, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -671,7 +709,7 @@ mod tests { // Get in "default" returns port 8080. let got = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -685,7 +723,7 @@ mod tests { // Get in "beta" returns port 9090. let got = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "beta".to_string(), @@ -699,7 +737,7 @@ mod tests { // List in each workspace returns 1 service. let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 100, offset: 0, @@ -718,7 +756,7 @@ mod tests { let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 100, offset: 0, @@ -738,7 +776,7 @@ mod tests { // Delete in "default" does not affect "beta". let deleted = handle_delete_service( &state, - Request::new(DeleteServiceRequest { + authed_request(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "default".to_string(), @@ -751,7 +789,7 @@ mod tests { let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: "my-sandbox".to_string(), limit: 100, offset: 0, @@ -766,7 +804,7 @@ mod tests { let got = handle_get_service( &state, - Request::new(GetServiceRequest { + authed_request(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), workspace: "beta".to_string(), @@ -781,7 +819,7 @@ mod tests { // Re-create the "default" service. handle_expose_service( &state, - Request::new(ExposeServiceRequest { + authed_request(ExposeServiceRequest { sandbox: "my-sandbox".to_string(), service: "api".to_string(), target_port: 3000, @@ -794,7 +832,7 @@ mod tests { let listed = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: String::new(), limit: 100, offset: 0, @@ -810,7 +848,7 @@ mod tests { // all_workspaces with non-empty workspace is rejected. let err = handle_list_services( &state, - Request::new(ListServicesRequest { + authed_request(ListServicesRequest { sandbox: String::new(), limit: 100, offset: 0, diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index e7357153a2..93984cbf39 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -22,6 +22,8 @@ use prost::Message; use tonic::{Request, Response, Status}; use crate::ServerState; +use crate::auth::principal::Principal; +use crate::auth::workspace_authz::{AuthGrant, MinWorkspaceRole, authorize_workspace}; use crate::persistence::{ DRAFT_CHUNK_OBJECT_TYPE, ObjectLabels, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms, @@ -46,6 +48,40 @@ impl ObjectType for WorkspaceMember { } } +async fn filter_workspaces_by_membership( + state: &Arc, + principal: &Principal, + workspaces: Vec, +) -> Result, Status> { + let subject = match principal { + Principal::User(u) => { + if crate::auth::workspace_authz::is_platform_admin_principal( + &u.identity.roles, + &state.admin_role, + ) { + return Ok(workspaces); + } + &u.identity.subject + } + Principal::Sandbox(_) => return Ok(workspaces), + Principal::Anonymous => return Err(Status::unauthenticated("authentication required")), + }; + + let mut visible = Vec::new(); + for ws in workspaces { + let ws_name = ws.metadata.as_ref().map_or("", |m| m.name.as_str()); + let member = state + .store + .get_message_by_name::(ws_name, subject) + .await + .map_err(|e| Status::internal(format!("membership check failed: {e}")))?; + if member.is_some() { + visible.push(ws); + } + } + Ok(visible) +} + fn validate_workspace_name(name: &str) -> Result<(), Status> { if name.is_empty() { return Err(Status::invalid_argument("workspace name is required")); @@ -107,9 +143,6 @@ pub async fn resolve_profile_workspace( /// carries the workspace's termination state so create-path handlers can reject /// operations on workspaces that are being deleted. /// -/// TODO(phase2): this only validates existence. Workspace membership enforcement -/// (checking the caller is a member of the resolved workspace) is deferred to -/// Phase 2. pub async fn resolve_workspace( store: &crate::persistence::Store, workspace: &str, @@ -213,10 +246,19 @@ pub(super) async fn handle_get_workspace( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let name = request.into_inner().name; if name.is_empty() { return Err(Status::invalid_argument("name is required")); } + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &name, + MinWorkspaceRole::User, + ) + .await?; let workspace: Workspace = state .store @@ -234,6 +276,7 @@ pub(super) async fn handle_list_workspaces( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); @@ -251,6 +294,8 @@ pub(super) async fn handle_list_workspaces( .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? }; + let workspaces = filter_workspaces_by_membership(state, &principal, workspaces).await?; + Ok(Response::new(ListWorkspacesResponse { workspaces })) } @@ -412,11 +457,20 @@ pub(super) async fn handle_add_workspace_member( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = resolve_workspace(&state.store, &req.workspace) .await? .ensure_active()?; + let authz = authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.principal_subject.is_empty() { return Err(Status::invalid_argument("principal_subject is required")); @@ -428,6 +482,11 @@ pub(super) async fn handle_add_workspace_member( "role must be USER or ADMIN, not UNSPECIFIED", )); } + if role == WorkspaceRole::Admin && authz.grant != AuthGrant::PlatformAdmin { + return Err(Status::permission_denied( + "only platform admins can assign the workspace admin role", + )); + } let count = state .store @@ -504,9 +563,18 @@ pub(super) async fn handle_remove_workspace_member( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = resolve_workspace(&state.store, &req.workspace).await?.name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; if req.principal_subject.is_empty() { return Err(Status::invalid_argument("principal_subject is required")); @@ -529,9 +597,18 @@ pub(super) async fn handle_list_workspace_members( state: &Arc, request: Request, ) -> Result, Status> { + let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = resolve_workspace(&state.store, &req.workspace).await?.name; + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); @@ -550,7 +627,7 @@ mod tests { use openshell_core::proto::datamodel::v1::ObjectMeta; use tonic::{Code, Request}; - use crate::grpc::test_support::test_server_state; + use crate::grpc::test_support::{authed_request, test_server_state}; #[tokio::test] async fn create_workspace_returns_metadata() { @@ -623,7 +700,7 @@ mod tests { let resp = handle_get_workspace( &state, - Request::new(GetWorkspaceRequest { + authed_request(GetWorkspaceRequest { name: "fetch-me".to_string(), }), ) @@ -643,7 +720,7 @@ mod tests { let err = handle_get_workspace( &state, - Request::new(GetWorkspaceRequest { + authed_request(GetWorkspaceRequest { name: "no-such-ws".to_string(), }), ) @@ -659,7 +736,7 @@ mod tests { let err = handle_get_workspace( &state, - Request::new(GetWorkspaceRequest { + authed_request(GetWorkspaceRequest { name: String::new(), }), ) @@ -877,7 +954,7 @@ mod tests { let resp = handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "alice@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -893,7 +970,7 @@ mod tests { handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "bob@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -904,7 +981,7 @@ mod tests { let list = handle_list_workspace_members( &state, - Request::new(ListWorkspaceMembersRequest { + authed_request(ListWorkspaceMembersRequest { workspace: "default".to_string(), limit: 100, offset: 0, @@ -923,7 +1000,7 @@ mod tests { handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "charlie@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -934,7 +1011,7 @@ mod tests { let resp = handle_remove_workspace_member( &state, - Request::new(RemoveWorkspaceMemberRequest { + authed_request(RemoveWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "charlie@example.com".to_string(), }), @@ -946,7 +1023,7 @@ mod tests { let list = handle_list_workspace_members( &state, - Request::new(ListWorkspaceMembersRequest { + authed_request(ListWorkspaceMembersRequest { workspace: "default".to_string(), limit: 100, offset: 0, @@ -965,7 +1042,7 @@ mod tests { handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "dave@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -976,7 +1053,7 @@ mod tests { let err = handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "default".to_string(), principal_subject: "dave@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -1004,7 +1081,7 @@ mod tests { handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "cleanup-test".to_string(), principal_subject: "alice@example.com".to_string(), role: WorkspaceRole::Admin.into(), @@ -1015,7 +1092,7 @@ mod tests { handle_add_workspace_member( &state, - Request::new(AddWorkspaceMemberRequest { + authed_request(AddWorkspaceMemberRequest { workspace: "cleanup-test".to_string(), principal_subject: "bob@example.com".to_string(), role: WorkspaceRole::User.into(), @@ -1026,7 +1103,7 @@ mod tests { let list = handle_list_workspace_members( &state, - Request::new(ListWorkspaceMembersRequest { + authed_request(ListWorkspaceMembersRequest { workspace: "cleanup-test".to_string(), limit: 100, offset: 0, @@ -1275,7 +1352,7 @@ mod tests { let resp = handle_list_workspaces( &state, - Request::new(ListWorkspacesRequest { + authed_request(ListWorkspacesRequest { label_selector: "env=staging".to_string(), ..Default::default() }), @@ -1293,7 +1370,7 @@ mod tests { let empty = handle_list_workspaces( &state, - Request::new(ListWorkspacesRequest { + authed_request(ListWorkspacesRequest { label_selector: "env=production".to_string(), ..Default::default() }), diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 97217c2565..0837ffb6d6 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -17,7 +17,6 @@ use openshell_core::{ObjectId, ObjectLabels, ObjectWorkspace}; use openshell_providers::normalize_provider_type; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; -use openshell_server_macros::rpc_authz; use prost::Message as _; use std::collections::HashMap; use std::sync::Arc; @@ -26,6 +25,7 @@ use tonic::{Request, Response, Status}; use crate::{ ServerState, + auth::workspace_authz::{MinWorkspaceRole, authorize_workspace}, persistence::{ObjectName, ObjectType, Store, WriteCondition, current_time_ms}, }; @@ -62,10 +62,8 @@ impl ObjectType for InferenceRoute { } } -#[rpc_authz(service = "openshell.inference.v1.Inference")] #[tonic::async_trait] impl Inference for InferenceService { - #[rpc_auth(auth = "sandbox")] async fn get_inference_bundle( &self, request: Request, @@ -88,16 +86,24 @@ impl Inference for InferenceService { .map(Response::new) } - #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] async fn set_inference_route( &self, request: Request, ) -> Result, Status> { + let principal = crate::grpc::extract_principal(&request)?; let req = request.into_inner(); let workspace = crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) .await? .ensure_active()?; + authorize_workspace( + &self.state.store, + &self.state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let route_name = effective_route_name(&req.route_name)?; let verify = !req.no_verify; let route = upsert_inference_route( @@ -129,16 +135,24 @@ impl Inference for InferenceService { })) } - #[rpc_auth(auth = "bearer", scope = "inference:read", role = "user")] async fn get_inference_route( &self, request: Request, ) -> Result, Status> { + let principal = crate::grpc::extract_principal(&request)?; let req = request.into_inner(); let workspace = crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &self.state.store, + &self.state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::User, + ) + .await?; let route_name = effective_route_name(&req.route_name)?; let route = self .state @@ -173,16 +187,24 @@ impl Inference for InferenceService { })) } - #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] async fn delete_inference_route( &self, request: Request, ) -> Result, Status> { + let principal = crate::grpc::extract_principal(&request)?; let req = request.into_inner(); let workspace = crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) .await? .name; + authorize_workspace( + &self.state.store, + &self.state.admin_role, + &principal, + &workspace, + MinWorkspaceRole::Admin, + ) + .await?; let route_name = effective_route_name(&req.route_name)?; let deleted = self .state diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a75534d397..fc1e61086f 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -161,6 +161,11 @@ pub struct ServerState { /// Gateway-local provider profile sources. User-imported profiles are read /// on demand when the user source is configured. pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, + + /// OIDC admin role name for workspace-level authorization. + /// Empty when OIDC is not configured — `authorize_workspace()` treats + /// every authenticated user as Platform Admin in that case. + pub admin_role: String, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -194,6 +199,10 @@ impl ServerState { oidc_cache: Option>, ) -> Self { let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); + let admin_role = config + .oidc + .as_ref() + .map_or_else(String::new, |oidc| oidc.admin_role.clone()); Self { config, store, @@ -215,6 +224,7 @@ impl ServerState { gateway_interceptors: None, provider_profile_sources: provider_profile_sources::ProviderProfileSources::with_default_sources(), + admin_role, } } } diff --git a/e2e/python/oidc/__init__.py b/e2e/python/oidc/__init__.py new file mode 100644 index 0000000000..e5725ea5a4 --- /dev/null +++ b/e2e/python/oidc/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/e2e/python/oidc/helpers.py b/e2e/python/oidc/helpers.py new file mode 100644 index 0000000000..586e24787e --- /dev/null +++ b/e2e/python/oidc/helpers.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared helpers for OIDC e2e tests. + +Provides Keycloak token acquisition, gRPC channel setup, and JWT utilities. +""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.parse +import urllib.request +from pathlib import Path + +import grpc + +from openshell._proto import openshell_pb2_grpc + +KEYCLOAK_REALM = "openshell" + + +def _xdg_config_home() -> Path: + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + +def keycloak_url() -> str: + """Derive the Keycloak URL from the gateway's stored OIDC issuer. + + The server validates the issuer claim in JWTs, so the token must be + requested from the same base URL the server was configured with + (typically the host IP, not localhost). + """ + if url := os.environ.get("OPENSHELL_KEYCLOAK_URL"): + return url + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + metadata_path = ( + _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" + ) + if metadata_path.exists(): + metadata = json.loads(metadata_path.read_text()) + issuer = metadata.get("oidc_issuer", "") + if issuer: + idx = issuer.find("/realms/") + if idx > 0: + return issuer[:idx] + return "http://localhost:8180" + + +TOKEN_ENDPOINT = ( + f"{keycloak_url()}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" +) + + +def _gateway_endpoint() -> tuple[str, bool]: + """Read the active gateway endpoint from metadata.""" + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + metadata_path = ( + _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" + ) + metadata = json.loads(metadata_path.read_text()) + endpoint = metadata["gateway_endpoint"] + is_tls = endpoint.startswith("https://") + return endpoint, is_tls + + +def _mtls_dir() -> Path: + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + return _xdg_config_home() / "openshell" / "gateways" / cluster_name / "mtls" + + +def _token_request(data: dict[str, str]) -> str: + """POST to the Keycloak token endpoint and return the access token.""" + encoded = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(TOKEN_ENDPOINT, data=encoded) + with urllib.request.urlopen(req, timeout=10) as resp: + body = json.loads(resp.read()) + return body["access_token"] + + +def get_token( + username: str, + password: str, + *, + client_id: str = "openshell-cli", + scopes: str | None = None, +) -> str: + """Get an access token from Keycloak via password grant.""" + data = { + "grant_type": "password", + "client_id": client_id, + "username": username, + "password": password, + } + if scopes: + data["scope"] = scopes + return _token_request(data) + + +def get_ci_token( + *, + client_id: str = "openshell-ci", + client_secret: str = "ci-test-secret", +) -> str: + """Get an access token via client credentials grant.""" + return _token_request( + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + ) + + +def grpc_channel() -> grpc.Channel: + """Create a gRPC channel to the gateway with mTLS transport.""" + endpoint, is_tls = _gateway_endpoint() + parsed = urllib.parse.urlparse(endpoint) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or (443 if is_tls else 80) + target = f"{host}:{port}" + + if is_tls: + mtls = _mtls_dir() + ca_cert = (mtls / "ca.crt").read_bytes() + client_cert = (mtls / "tls.crt").read_bytes() + client_key = (mtls / "tls.key").read_bytes() + creds = grpc.ssl_channel_credentials( + root_certificates=ca_cert, + private_key=client_key, + certificate_chain=client_cert, + ) + return grpc.secure_channel(target, creds) + return grpc.insecure_channel(target) + + +def stub_with_token( + token: str, +) -> tuple[openshell_pb2_grpc.OpenShellStub, list[tuple[str, str]]]: + """Create a gRPC stub that injects a Bearer token.""" + channel = grpc_channel() + return openshell_pb2_grpc.OpenShellStub(channel), [ + ("authorization", f"Bearer {token}") + ] + + +def extract_sub(token: str) -> str: + """Extract the 'sub' claim from a JWT access token.""" + payload = token.split(".")[1] + padded = payload + "=" * (4 - len(payload) % 4) + decoded = base64.urlsafe_b64decode(padded) + claims = json.loads(decoded) + return claims["sub"] diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py index 5816868dd3..bbea1aac3d 100644 --- a/e2e/python/oidc/oidc_auth_test.py +++ b/e2e/python/oidc/oidc_auth_test.py @@ -14,51 +14,19 @@ from __future__ import annotations import contextlib -import json import os -import urllib.parse -import urllib.request -from pathlib import Path import grpc import pytest from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc -KEYCLOAK_REALM = "openshell" - - -def _xdg_config_home() -> Path: - return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - - -def _keycloak_url() -> str: - """Derive the Keycloak URL from the gateway's stored OIDC issuer. - - The server validates the issuer claim in JWTs, so the token must be - requested from the same base URL the server was configured with - (typically the host IP, not localhost). - """ - if url := os.environ.get("OPENSHELL_KEYCLOAK_URL"): - return url - cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") - metadata_path = ( - _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" - ) - if metadata_path.exists(): - metadata = json.loads(metadata_path.read_text()) - issuer = metadata.get("oidc_issuer", "") - if issuer: - # issuer is like "http://192.168.4.172:8180/realms/openshell" - # extract base URL before /realms/ - idx = issuer.find("/realms/") - if idx > 0: - return issuer[:idx] - return "http://localhost:8180" - - -TOKEN_ENDPOINT = ( - f"{_keycloak_url()}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" +from .helpers import ( + extract_sub, + get_ci_token, + get_token, + grpc_channel, + stub_with_token, ) pytestmark = pytest.mark.skipif( @@ -67,96 +35,6 @@ def _keycloak_url() -> str: ) -def _gateway_endpoint() -> tuple[str, bool]: - """Read the active gateway endpoint from metadata.""" - cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") - metadata_path = ( - _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" - ) - metadata = json.loads(metadata_path.read_text()) - endpoint = metadata["gateway_endpoint"] - is_tls = endpoint.startswith("https://") - return endpoint, is_tls - - -def _mtls_dir() -> Path: - cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") - return _xdg_config_home() / "openshell" / "gateways" / cluster_name / "mtls" - - -def _token_request(data: dict[str, str]) -> str: - """POST to the Keycloak token endpoint and return the access token.""" - encoded = urllib.parse.urlencode(data).encode() - req = urllib.request.Request(TOKEN_ENDPOINT, data=encoded) - with urllib.request.urlopen(req, timeout=10) as resp: - body = json.loads(resp.read()) - return body["access_token"] - - -def _get_token( - username: str, - password: str, - *, - client_id: str = "openshell-cli", - scopes: str | None = None, -) -> str: - """Get an access token from Keycloak via password grant.""" - data = { - "grant_type": "password", - "client_id": client_id, - "username": username, - "password": password, - } - if scopes: - data["scope"] = scopes - return _token_request(data) - - -def _get_ci_token( - *, - client_id: str = "openshell-ci", - client_secret: str = "ci-test-secret", -) -> str: - """Get an access token via client credentials grant.""" - return _token_request( - { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - } - ) - - -def _grpc_channel() -> grpc.Channel: - """Create a gRPC channel to the gateway with mTLS transport.""" - endpoint, is_tls = _gateway_endpoint() - parsed = urllib.parse.urlparse(endpoint) - host = parsed.hostname or "127.0.0.1" - port = parsed.port or (443 if is_tls else 80) - target = f"{host}:{port}" - - if is_tls: - mtls = _mtls_dir() - ca_cert = (mtls / "ca.crt").read_bytes() - client_cert = (mtls / "tls.crt").read_bytes() - client_key = (mtls / "tls.key").read_bytes() - creds = grpc.ssl_channel_credentials( - root_certificates=ca_cert, - private_key=client_key, - certificate_chain=client_cert, - ) - return grpc.secure_channel(target, creds) - return grpc.insecure_channel(target) - - -def _stub_with_token(token: str) -> tuple[openshell_pb2_grpc.OpenShellStub, list[tuple[str, str]]]: - """Create a gRPC stub that injects a Bearer token.""" - channel = _grpc_channel() - return openshell_pb2_grpc.OpenShellStub(channel), [ - ("authorization", f"Bearer {token}") - ] - - # ── RBAC Tests ──────────────────────────────────────────────────────── @@ -164,11 +42,11 @@ class TestRbac: """Test role-based access control.""" def test_admin_can_create_provider(self) -> None: - token = _get_token("admin@test", "admin", scopes="openid openshell:all") - stub, metadata = _stub_with_token(token) + token = get_token("admin@test", "admin", scopes="openid openshell:all") + stub, metadata = stub_with_token(token) req = openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( - name="e2e-oidc-admin-test", + metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-admin-test"), type="claude", credentials={"API_KEY": "test-value"}, ) @@ -188,11 +66,11 @@ def test_admin_can_create_provider(self) -> None: ) def test_user_cannot_create_provider(self) -> None: - token = _get_token("user@test", "user", scopes="openid openshell:all") - stub, metadata = _stub_with_token(token) + token = get_token("user@test", "user", scopes="openid openshell:all") + stub, metadata = stub_with_token(token) req = openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( - name="e2e-oidc-user-blocked", + metadata=datamodel_pb2.ObjectMeta(name="e2e-oidc-user-blocked"), type="claude", credentials={"API_KEY": "test-value"}, ) @@ -200,22 +78,48 @@ def test_user_cannot_create_provider(self) -> None: with pytest.raises(grpc.RpcError) as exc_info: stub.CreateProvider(req, metadata=metadata) assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED - assert "openshell-admin" in exc_info.value.details() def test_user_can_list_sandboxes(self) -> None: - token = _get_token("user@test", "user", scopes="openid openshell:all") - stub, metadata = _stub_with_token(token) - stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + admin_token = get_token("admin@test", "admin", scopes="openid openshell:all") + admin_stub, admin_md = stub_with_token(admin_token) + user_token = get_token("user@test", "user", scopes="openid openshell:all") + user_sub = extract_sub(user_token) + user_stub, user_md = stub_with_token(user_token) + + with contextlib.suppress(grpc.RpcError): + admin_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace="default", + principal_subject=user_sub, + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=admin_md, + ) + try: + user_stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest(), metadata=user_md + ) + finally: + with contextlib.suppress(grpc.RpcError): + admin_stub.RemoveWorkspaceMember( + openshell_pb2.RemoveWorkspaceMemberRequest( + workspace="default", principal_subject=user_sub + ), + metadata=admin_md, + ) - def test_unauthenticated_request_rejected(self) -> None: - channel = _grpc_channel() + def test_request_without_bearer_token_rejected(self) -> None: + channel = grpc_channel() stub = openshell_pb2_grpc.OpenShellStub(channel) with pytest.raises(grpc.RpcError) as exc_info: stub.ListSandboxes(openshell_pb2.ListSandboxesRequest()) - assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED + assert exc_info.value.code() in ( + grpc.StatusCode.UNAUTHENTICATED, + grpc.StatusCode.PERMISSION_DENIED, + ) def test_health_does_not_require_auth(self) -> None: - channel = _grpc_channel() + channel = grpc_channel() stub = openshell_pb2_grpc.OpenShellStub(channel) resp = stub.Health(openshell_pb2.HealthRequest()) assert resp.status == openshell_pb2.SERVICE_STATUS_HEALTHY @@ -237,31 +141,31 @@ class TestScopes: ) def test_sandbox_scoped_token_can_list_sandboxes(self) -> None: - token = _get_token( + token = get_token( "admin@test", "admin", scopes="openid sandbox:read sandbox:write" ) - stub, metadata = _stub_with_token(token) + stub, metadata = stub_with_token(token) stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) def test_sandbox_scoped_token_cannot_list_providers(self) -> None: - token = _get_token( + token = get_token( "admin@test", "admin", scopes="openid sandbox:read sandbox:write" ) - stub, metadata = _stub_with_token(token) + stub, metadata = stub_with_token(token) with pytest.raises(grpc.RpcError) as exc_info: stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata) assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED assert "provider:read" in exc_info.value.details() def test_openshell_all_grants_full_access(self) -> None: - token = _get_token("admin@test", "admin", scopes="openid openshell:all") - stub, metadata = _stub_with_token(token) + token = get_token("admin@test", "admin", scopes="openid openshell:all") + stub, metadata = stub_with_token(token) stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata) def test_no_openshell_scopes_denied(self) -> None: - token = _get_token("admin@test", "admin") - stub, metadata = _stub_with_token(token) + token = get_token("admin@test", "admin") + stub, metadata = stub_with_token(token) with pytest.raises(grpc.RpcError) as exc_info: stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED @@ -274,6 +178,28 @@ class TestClientCredentials: """Test CI/automation client credentials flow.""" def test_ci_token_can_list_sandboxes(self) -> None: - token = _get_ci_token() - stub, metadata = _stub_with_token(token) - stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + admin_token = get_token("admin@test", "admin", scopes="openid openshell:all") + admin_stub, admin_md = stub_with_token(admin_token) + ci_token = get_ci_token() + ci_sub = extract_sub(ci_token) + ci_stub, ci_md = stub_with_token(ci_token) + + with contextlib.suppress(grpc.RpcError): + admin_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace="default", + principal_subject=ci_sub, + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=admin_md, + ) + try: + ci_stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=ci_md) + finally: + with contextlib.suppress(grpc.RpcError): + admin_stub.RemoveWorkspaceMember( + openshell_pb2.RemoveWorkspaceMemberRequest( + workspace="default", principal_subject=ci_sub + ), + metadata=admin_md, + ) diff --git a/e2e/python/oidc/workspace_authz_test.py b/e2e/python/oidc/workspace_authz_test.py new file mode 100644 index 0000000000..0aeeb12ef1 --- /dev/null +++ b/e2e/python/oidc/workspace_authz_test.py @@ -0,0 +1,951 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end tests for workspace-scoped authorization enforcement. + +Validates that every workspace-scoped RPC enforces membership and role +checks when OIDC is configured. Uses two Keycloak users: + +- admin@test — openshell-admin role → Platform Admin (bypasses membership) +- user@test — openshell-user role → must be an explicit workspace member + +Skip condition: set OPENSHELL_E2E_OIDC=1 to enable these tests. +""" + +from __future__ import annotations + +import contextlib +import os +from typing import TYPE_CHECKING, Any + +import grpc +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + +from openshell._proto import ( + datamodel_pb2, + inference_pb2, + inference_pb2_grpc, + openshell_pb2, + openshell_pb2_grpc, +) + +from .helpers import extract_sub, get_token, grpc_channel, stub_with_token + +WS = "e2e-authz-test" + +pytestmark = pytest.mark.skipif( + os.environ.get("OPENSHELL_E2E_OIDC") != "1", + reason="OIDC e2e tests disabled (set OPENSHELL_E2E_OIDC=1)", +) + + +# ── Helpers ────────────────────────────────────────────────────────────── + + +def _admin_token() -> str: + return get_token("admin@test", "admin", scopes="openid openshell:all") + + +def _user_token() -> str: + return get_token("user@test", "user", scopes="openid openshell:all") + + +def _add_member( + stub: openshell_pb2_grpc.OpenShellStub, + metadata: list[tuple[str, str]], + workspace: str, + subject: str, + role: int, +) -> None: + stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=workspace, + principal_subject=subject, + role=role, + ), + metadata=metadata, + ) + + +def _remove_member( + stub: openshell_pb2_grpc.OpenShellStub, + metadata: list[tuple[str, str]], + workspace: str, + subject: str, +) -> None: + with contextlib.suppress(grpc.RpcError): + stub.RemoveWorkspaceMember( + openshell_pb2.RemoveWorkspaceMemberRequest( + workspace=workspace, + principal_subject=subject, + ), + metadata=metadata, + ) + + +# ── RPC call builders for parametrized non-member rejection tests ──────── +# +# Each entry is (test_id, callable(stub, metadata) -> response). +# The callable constructs a minimal valid request for the given RPC. + + +def _workspace_rpcs() -> list[tuple[str, Callable]]: + """All workspace-scoped RPCs that accept a workspace field.""" + return [ + # ── Workspace domain ── + ( + "GetWorkspace", + lambda s, m: s.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=WS), metadata=m + ), + ), + ( + "ListWorkspaceMembers", + lambda s, m: s.ListWorkspaceMembers( + openshell_pb2.ListWorkspaceMembersRequest(workspace=WS), metadata=m + ), + ), + ( + "AddWorkspaceMember", + lambda s, m: s.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=WS, + principal_subject="fake", + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=m, + ), + ), + ( + "RemoveWorkspaceMember", + lambda s, m: s.RemoveWorkspaceMember( + openshell_pb2.RemoveWorkspaceMemberRequest( + workspace=WS, + principal_subject="fake", + ), + metadata=m, + ), + ), + # ── Sandbox domain ── + ( + "CreateSandbox", + lambda s, m: s.CreateSandbox( + openshell_pb2.CreateSandboxRequest( + workspace=WS, + spec=openshell_pb2.SandboxSpec( + template=openshell_pb2.SandboxTemplate(image="ubuntu:24.04") + ), + ), + metadata=m, + ), + ), + ( + "GetSandbox", + lambda s, m: s.GetSandbox( + openshell_pb2.GetSandboxRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ListSandboxes", + lambda s, m: s.ListSandboxes( + openshell_pb2.ListSandboxesRequest(workspace=WS), metadata=m + ), + ), + ( + "DeleteSandbox", + lambda s, m: s.DeleteSandbox( + openshell_pb2.DeleteSandboxRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ListSandboxProviders", + lambda s, m: s.ListSandboxProviders( + openshell_pb2.ListSandboxProvidersRequest( + sandbox_name="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "AttachSandboxProvider", + lambda s, m: s.AttachSandboxProvider( + openshell_pb2.AttachSandboxProviderRequest( + sandbox_name="nonexistent", + provider_name="nonexistent", + workspace=WS, + ), + metadata=m, + ), + ), + ( + "DetachSandboxProvider", + lambda s, m: s.DetachSandboxProvider( + openshell_pb2.DetachSandboxProviderRequest( + sandbox_name="nonexistent", + provider_name="nonexistent", + workspace=WS, + ), + metadata=m, + ), + ), + ( + "GetSandboxLogs", + lambda s, m: s.GetSandboxLogs( + openshell_pb2.GetSandboxLogsRequest( + sandbox_id="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + # ── Provider domain ── + ( + "CreateProvider", + lambda s, m: s.CreateProvider( + openshell_pb2.CreateProviderRequest( + workspace=WS, + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="authz-test", workspace=WS + ), + type="claude", + credentials={"K": "v"}, + ), + ), + metadata=m, + ), + ), + ( + "GetProvider", + lambda s, m: s.GetProvider( + openshell_pb2.GetProviderRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ListProviders", + lambda s, m: s.ListProviders( + openshell_pb2.ListProvidersRequest(workspace=WS), metadata=m + ), + ), + ( + "UpdateProvider", + lambda s, m: s.UpdateProvider( + openshell_pb2.UpdateProviderRequest( + workspace=WS, + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="nonexistent", workspace=WS + ), + type="claude", + credentials={"K": "v"}, + ), + ), + metadata=m, + ), + ), + ( + "DeleteProvider", + lambda s, m: s.DeleteProvider( + openshell_pb2.DeleteProviderRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ListProviderProfiles", + lambda s, m: s.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(workspace=WS), metadata=m + ), + ), + ( + "GetProviderProfile", + lambda s, m: s.GetProviderProfile( + openshell_pb2.GetProviderProfileRequest(id="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ImportProviderProfiles", + lambda s, m: s.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest(workspace=WS, profiles=[]), + metadata=m, + ), + ), + ( + "UpdateProviderProfiles", + lambda s, m: s.UpdateProviderProfiles( + openshell_pb2.UpdateProviderProfilesRequest( + workspace=WS, id="nonexistent" + ), + metadata=m, + ), + ), + ( + "LintProviderProfiles", + lambda s, m: s.LintProviderProfiles( + openshell_pb2.LintProviderProfilesRequest(workspace=WS, profiles=[]), + metadata=m, + ), + ), + ( + "DeleteProviderProfile", + lambda s, m: s.DeleteProviderProfile( + openshell_pb2.DeleteProviderProfileRequest( + id="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "GetProviderRefreshStatus", + lambda s, m: s.GetProviderRefreshStatus( + openshell_pb2.GetProviderRefreshStatusRequest( + provider="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "ConfigureProviderRefresh", + lambda s, m: s.ConfigureProviderRefresh( + openshell_pb2.ConfigureProviderRefreshRequest( + provider="nonexistent", + credential_key="k", + strategy=openshell_pb2.PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, + workspace=WS, + ), + metadata=m, + ), + ), + ( + "RotateProviderCredential", + lambda s, m: s.RotateProviderCredential( + openshell_pb2.RotateProviderCredentialRequest( + provider="nonexistent", + credential_key="k", + workspace=WS, + ), + metadata=m, + ), + ), + ( + "DeleteProviderRefresh", + lambda s, m: s.DeleteProviderRefresh( + openshell_pb2.DeleteProviderRefreshRequest( + provider="nonexistent", + credential_key="k", + workspace=WS, + ), + metadata=m, + ), + ), + # ── Service domain ── + ( + "ExposeService", + lambda s, m: s.ExposeService( + openshell_pb2.ExposeServiceRequest( + sandbox="nonexistent", + service="svc", + target_port=8080, + workspace=WS, + ), + metadata=m, + ), + ), + ( + "GetService", + lambda s, m: s.GetService( + openshell_pb2.GetServiceRequest( + sandbox="nonexistent", service="svc", workspace=WS + ), + metadata=m, + ), + ), + ( + "ListServices", + lambda s, m: s.ListServices( + openshell_pb2.ListServicesRequest(workspace=WS), metadata=m + ), + ), + ( + "DeleteService", + lambda s, m: s.DeleteService( + openshell_pb2.DeleteServiceRequest( + sandbox="nonexistent", service="svc", workspace=WS + ), + metadata=m, + ), + ), + # ── Policy domain ── + ( + "GetSandboxPolicyStatus", + lambda s, m: s.GetSandboxPolicyStatus( + openshell_pb2.GetSandboxPolicyStatusRequest( + name="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "ListSandboxPolicies", + lambda s, m: s.ListSandboxPolicies( + openshell_pb2.ListSandboxPoliciesRequest( + name="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "GetDraftPolicy", + lambda s, m: s.GetDraftPolicy( + openshell_pb2.GetDraftPolicyRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "ApproveDraftChunk", + lambda s, m: s.ApproveDraftChunk( + openshell_pb2.ApproveDraftChunkRequest( + name="nonexistent", chunk_id="x", workspace=WS + ), + metadata=m, + ), + ), + ( + "RejectDraftChunk", + lambda s, m: s.RejectDraftChunk( + openshell_pb2.RejectDraftChunkRequest( + name="nonexistent", chunk_id="x", workspace=WS + ), + metadata=m, + ), + ), + ( + "ApproveAllDraftChunks", + lambda s, m: s.ApproveAllDraftChunks( + openshell_pb2.ApproveAllDraftChunksRequest( + name="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "EditDraftChunk", + lambda s, m: s.EditDraftChunk( + openshell_pb2.EditDraftChunkRequest( + name="nonexistent", chunk_id="x", workspace=WS + ), + metadata=m, + ), + ), + ( + "UndoDraftChunk", + lambda s, m: s.UndoDraftChunk( + openshell_pb2.UndoDraftChunkRequest( + name="nonexistent", chunk_id="x", workspace=WS + ), + metadata=m, + ), + ), + ( + "ClearDraftChunks", + lambda s, m: s.ClearDraftChunks( + openshell_pb2.ClearDraftChunksRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + ( + "GetDraftHistory", + lambda s, m: s.GetDraftHistory( + openshell_pb2.GetDraftHistoryRequest(name="nonexistent", workspace=WS), + metadata=m, + ), + ), + # ── Inference domain ── + ( + "SetInferenceRoute", + lambda _s, m: _inference_stub().SetInferenceRoute( + inference_pb2.SetInferenceRouteRequest( + provider_name="nonexistent", workspace=WS + ), + metadata=m, + ), + ), + ( + "GetInferenceRoute", + lambda _s, m: _inference_stub().GetInferenceRoute( + inference_pb2.GetInferenceRouteRequest(workspace=WS), metadata=m + ), + ), + ( + "DeleteInferenceRoute", + lambda _s, m: _inference_stub().DeleteInferenceRoute( + inference_pb2.DeleteInferenceRouteRequest(workspace=WS), metadata=m + ), + ), + ] + + +_cached_inference_stub: inference_pb2_grpc.InferenceStub | None = None + + +def _inference_stub() -> inference_pb2_grpc.InferenceStub: + global _cached_inference_stub + if _cached_inference_stub is None: + _cached_inference_stub = inference_pb2_grpc.InferenceStub(grpc_channel()) + return _cached_inference_stub + + +# ── Test class ─────────────────────────────────────────────────────────── + + +class TestWorkspaceAuthorization: + """Workspace-scoped authorization enforcement tests.""" + + @pytest.fixture(autouse=True, scope="class") + def workspace(self) -> Any: + """Create a test workspace and tear it down after all tests.""" + token = _admin_token() + stub, metadata = stub_with_token(token) + + with contextlib.suppress(grpc.RpcError): + stub.CreateWorkspace( + openshell_pb2.CreateWorkspaceRequest(name=WS), + metadata=metadata, + ) + + yield WS + + with contextlib.suppress(grpc.RpcError): + stub.DeleteWorkspace( + openshell_pb2.DeleteWorkspaceRequest(name=WS), + metadata=metadata, + ) + + @pytest.fixture(scope="class") + def admin_ctx( + self, + ) -> tuple[openshell_pb2_grpc.OpenShellStub, list[tuple[str, str]]]: + token = _admin_token() + return stub_with_token(token) + + @pytest.fixture(scope="class") + def user_ctx( + self, + ) -> tuple[openshell_pb2_grpc.OpenShellStub, list[tuple[str, str]], str]: + token = _user_token() + stub, metadata = stub_with_token(token) + sub = extract_sub(token) + return stub, metadata, sub + + @pytest.fixture(scope="class") + def seed_provider(self, admin_ctx: Any, workspace: str) -> Any: + """Create a provider so read RPCs have data to return.""" + stub, metadata = admin_ctx + prov_name = "e2e-authz-provider" + with contextlib.suppress(grpc.RpcError): + stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + workspace=workspace, + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name=prov_name, workspace=workspace + ), + type="claude", + credentials={"API_KEY": "test"}, + ), + ), + metadata=metadata, + ) + yield prov_name + with contextlib.suppress(grpc.RpcError): + stub.DeleteProvider( + openshell_pb2.DeleteProviderRequest( + name=prov_name, workspace=workspace + ), + metadata=metadata, + ) + + # ── Test 1: Non-member rejection — workspace-field RPCs ────────── + + @pytest.mark.parametrize( + "rpc_name,call", + _workspace_rpcs(), + ids=[r[0] for r in _workspace_rpcs()], + ) + def test_non_member_rejected( + self, + rpc_name: str, + call: Callable, + user_ctx: Any, + ) -> None: + stub, metadata, _ = user_ctx + with pytest.raises(grpc.RpcError) as exc_info: + call(stub, metadata) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED, ( + f"{rpc_name}: expected PERMISSION_DENIED, got {exc_info.value.code()}" + ) + + # ── Test 2: Non-member rejection — dual-mode RPCs ──────────────── + + def test_non_member_rejected_update_config( + self, + user_ctx: Any, + ) -> None: + stub, metadata, _ = user_ctx + with pytest.raises(grpc.RpcError) as exc_info: + stub.UpdateConfig( + openshell_pb2.UpdateConfigRequest(name="nonexistent", workspace=WS), + metadata=metadata, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + # ── Test 3: Non-member ListWorkspaces returns filtered results ─── + + def test_non_member_list_workspaces_filtered( + self, + user_ctx: Any, + ) -> None: + stub, metadata, _ = user_ctx + resp = stub.ListWorkspaces( + openshell_pb2.ListWorkspacesRequest(), + metadata=metadata, + ) + ws_names = [w.metadata.name for w in resp.workspaces] + assert WS not in ws_names, ( + f"non-member should not see workspace {WS} in ListWorkspaces" + ) + + # ── Test 4: Platform Admin bypass ──────────────────────────────── + + @pytest.mark.usefixtures("seed_provider") + def test_platform_admin_get_workspace( + self, + admin_ctx: Any, + ) -> None: + stub, metadata = admin_ctx + resp = stub.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=WS), + metadata=metadata, + ) + assert resp.workspace.metadata.name == WS + + def test_platform_admin_list_sandboxes( + self, + admin_ctx: Any, + ) -> None: + stub, metadata = admin_ctx + stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest(workspace=WS), + metadata=metadata, + ) + + def test_platform_admin_get_provider( + self, + admin_ctx: Any, + seed_provider: str, + ) -> None: + stub, metadata = admin_ctx + resp = stub.GetProvider( + openshell_pb2.GetProviderRequest(name=seed_provider, workspace=WS), + metadata=metadata, + ) + assert resp.provider.metadata.name == seed_provider + + def test_platform_admin_list_services( + self, + admin_ctx: Any, + ) -> None: + stub, metadata = admin_ctx + stub.ListServices( + openshell_pb2.ListServicesRequest(workspace=WS), + metadata=metadata, + ) + + def test_platform_admin_get_draft_history( + self, + admin_ctx: Any, + ) -> None: + stub, metadata = admin_ctx + # May fail with NOT_FOUND for the sandbox name, but should not fail with PERMISSION_DENIED + try: + stub.GetDraftHistory( + openshell_pb2.GetDraftHistoryRequest(name="nonexistent", workspace=WS), + metadata=metadata, + ) + except grpc.RpcError as e: + assert e.code() != grpc.StatusCode.PERMISSION_DENIED + + # ── Test 5: User member — read operations succeed ──────────────── + + def test_user_member_read_operations( + self, + admin_ctx: Any, + user_ctx: Any, + seed_provider: str, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_USER + ) + try: + # GetWorkspace + resp = user_stub.GetWorkspace( + openshell_pb2.GetWorkspaceRequest(name=WS), + metadata=user_md, + ) + assert resp.workspace.metadata.name == WS + + # ListSandboxes + user_stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest(workspace=WS), + metadata=user_md, + ) + + # GetProvider + resp = user_stub.GetProvider( + openshell_pb2.GetProviderRequest(name=seed_provider, workspace=WS), + metadata=user_md, + ) + assert resp.provider.metadata.name == seed_provider + + # ListProviders + user_stub.ListProviders( + openshell_pb2.ListProvidersRequest(workspace=WS), + metadata=user_md, + ) + + # ListServices + user_stub.ListServices( + openshell_pb2.ListServicesRequest(workspace=WS), + metadata=user_md, + ) + + # ListWorkspaceMembers + user_stub.ListWorkspaceMembers( + openshell_pb2.ListWorkspaceMembersRequest(workspace=WS), + metadata=user_md, + ) + finally: + _remove_member(admin_stub, admin_md, WS, user_sub) + + # ── Test 6: User member — admin operations denied ──────────────── + + def test_user_member_admin_operations_denied( + self, + admin_ctx: Any, + user_ctx: Any, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_USER + ) + try: + # CreateProvider requires workspace admin + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + workspace=WS, + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="user-blocked", workspace=WS + ), + type="claude", + credentials={"K": "v"}, + ), + ), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + # AddWorkspaceMember requires workspace admin + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=WS, + principal_subject="fake-subject", + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + # ApproveDraftChunk requires workspace admin + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.ApproveDraftChunk( + openshell_pb2.ApproveDraftChunkRequest( + name="nonexistent", + chunk_id="x", + workspace=WS, + ), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + finally: + _remove_member(admin_stub, admin_md, WS, user_sub) + + # ── Test 7: Workspace Admin — admin operations succeed ─────────── + + def test_workspace_admin_can_create_provider( + self, + admin_ctx: Any, + user_ctx: Any, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_ADMIN + ) + prov_name = "e2e-authz-ws-admin-prov" + try: + user_stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + workspace=WS, + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta(name=prov_name, workspace=WS), + type="claude", + credentials={"K": "v"}, + ), + ), + metadata=user_md, + ) + + # Also test AddWorkspaceMember with User role + user_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=WS, + principal_subject="fake-member-subject", + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=user_md, + ) + _remove_member(admin_stub, admin_md, WS, "fake-member-subject") + finally: + with contextlib.suppress(grpc.RpcError): + admin_stub.DeleteProvider( + openshell_pb2.DeleteProviderRequest(name=prov_name, workspace=WS), + metadata=admin_md, + ) + _remove_member(admin_stub, admin_md, WS, user_sub) + + # ── Test 8: all_workspaces rejected for non-Platform-Admin ─────── + + def test_all_workspaces_rejected_for_workspace_admin( + self, + admin_ctx: Any, + user_ctx: Any, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_ADMIN + ) + try: + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest(all_workspaces=True), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.ListProviders( + openshell_pb2.ListProvidersRequest(all_workspaces=True), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.ListServices( + openshell_pb2.ListServicesRequest(all_workspaces=True), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + finally: + _remove_member(admin_stub, admin_md, WS, user_sub) + + # ── Test 9: Workspace Admin cannot assign Admin role ───────────── + + def test_workspace_admin_cannot_assign_admin_role( + self, + admin_ctx: Any, + user_ctx: Any, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_ADMIN + ) + try: + # Workspace Admin cannot assign Admin role + with pytest.raises(grpc.RpcError) as exc_info: + user_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=WS, + principal_subject="another-subject", + role=openshell_pb2.WORKSPACE_ROLE_ADMIN, + ), + metadata=user_md, + ) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + # But User role assignment succeeds + user_stub.AddWorkspaceMember( + openshell_pb2.AddWorkspaceMemberRequest( + workspace=WS, + principal_subject="another-subject", + role=openshell_pb2.WORKSPACE_ROLE_USER, + ), + metadata=user_md, + ) + _remove_member(admin_stub, admin_md, WS, "another-subject") + finally: + _remove_member(admin_stub, admin_md, WS, user_sub) + + # ── Test 10: ListWorkspaces filtered by membership ─────────────── + + def test_list_workspaces_filtered_by_membership( + self, + admin_ctx: Any, + user_ctx: Any, + ) -> None: + admin_stub, admin_md = admin_ctx + user_stub, user_md, user_sub = user_ctx + + ws2 = "e2e-authz-test-2" + with contextlib.suppress(grpc.RpcError): + admin_stub.CreateWorkspace( + openshell_pb2.CreateWorkspaceRequest(name=ws2), + metadata=admin_md, + ) + + _add_member( + admin_stub, admin_md, WS, user_sub, openshell_pb2.WORKSPACE_ROLE_USER + ) + try: + resp = user_stub.ListWorkspaces( + openshell_pb2.ListWorkspacesRequest(), + metadata=user_md, + ) + ws_names = [w.metadata.name for w in resp.workspaces] + assert WS in ws_names, f"member should see {WS}" + assert ws2 not in ws_names, f"non-member should not see {ws2}" + assert "default" not in ws_names, "non-member should not see default" + finally: + _remove_member(admin_stub, admin_md, WS, user_sub) + with contextlib.suppress(grpc.RpcError): + admin_stub.DeleteWorkspace( + openshell_pb2.DeleteWorkspaceRequest(name=ws2), + metadata=admin_md, + ) diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 07178d10b5..5a8028779a 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "atomic-waker" @@ -22,9 +22,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -80,9 +80,9 @@ dependencies = [ [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cfg-if" @@ -127,7 +127,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -149,20 +149,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "form_urlencoded" @@ -175,24 +169,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -201,32 +195,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -259,37 +253,20 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hex" @@ -299,9 +276,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -309,9 +286,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -319,9 +296,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -344,9 +321,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -365,9 +342,9 @@ dependencies = [ [[package]] name = "hyper-named-pipe" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", "hyper", @@ -375,7 +352,6 @@ dependencies = [ "pin-project-lite", "tokio", "tower-service", - "winapi", ] [[package]] @@ -495,12 +471,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -524,14 +494,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -545,21 +513,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "leb128fmt" -version = "0.1.0" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libyml" @@ -594,32 +556,32 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openshell-e2e" @@ -643,6 +605,7 @@ dependencies = [ "sha2", "tempfile", "tokio", + "url", ] [[package]] @@ -698,21 +661,11 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -737,14 +690,14 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -763,9 +716,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -809,7 +762,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -824,17 +777,11 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -842,29 +789,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -875,13 +822,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -933,14 +880,14 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures", @@ -976,18 +923,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -998,9 +945,20 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1015,40 +973,40 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1063,9 +1021,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1075,29 +1033,30 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1135,9 +1094,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1145,12 +1104,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "url" version = "2.5.8" @@ -1192,56 +1145,13 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "winapi" version = "0.3.9" @@ -1270,15 +1180,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1288,158 +1189,11 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -1466,28 +1220,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1507,7 +1261,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -1541,11 +1295,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index e4d99dc45c..0e58afa3c1 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -30,8 +30,14 @@ e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] +e2e-oidc-pkce = [] e2e-vm = ["e2e", "e2e-host-gateway"] +[[test]] +name = "oidc_pkce" +path = "tests/oidc_pkce.rs" +required-features = ["e2e-oidc-pkce"] + [[test]] name = "custom_image" path = "tests/custom_image.rs" @@ -130,6 +136,7 @@ rand = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" +url = "2" [dev-dependencies] serial_test = "3" diff --git a/e2e/rust/tests/oidc_pkce.rs b/e2e/rust/tests/oidc_pkce.rs new file mode 100644 index 0000000000..dadcb8d7dd --- /dev/null +++ b/e2e/rust/tests/oidc_pkce.rs @@ -0,0 +1,1506 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_os = "linux")] + +//! End-to-end coverage for interactive OIDC PKCE login and gateway RBAC. +//! +//! The test replaces Linux's `xdg-open` with a recorder, then drives the +//! captured Keycloak login URL with curl. This exercises the same loopback +//! callback and token exchange used by a real browser without requiring a GUI. +//! It logs in as the fixture identities and verifies standard-user and admin-only +//! actions against a live Docker- or Podman-backed gateway. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::fs::Permissions; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Output, Stdio}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use openshell_e2e::harness::binary::openshell_cmd; +use serde_json::Value; +use tokio::process::Command; +use tokio::sync::Mutex; +use url::Url; + +static SANDBOX_LIFECYCLE_LOCK: Mutex<()> = Mutex::const_new(()); + +#[derive(Clone, Copy)] +struct IdentityScenario { + gateway_name: &'static str, + username: &'static str, + password: &'static str, + expected_role: &'static str, +} + +const ADMIN: IdentityScenario = IdentityScenario { + gateway_name: "oidc-pkce-admin", + username: "admin@test", + password: "admin", + expected_role: "openshell-admin", +}; + +const USER: IdentityScenario = IdentityScenario { + gateway_name: "oidc-pkce-user", + username: "user@test", + password: "user", + expected_role: "openshell-user", +}; + +const USER_B: IdentityScenario = IdentityScenario { + gateway_name: "oidc-pkce-user-b", + username: "user-b@test", + password: "user-b", + expected_role: "openshell-user", +}; + +struct LoginSession { + config_home: tempfile::TempDir, + identity: IdentityScenario, + subject: String, +} + +#[tokio::test] +async fn admin_can_list_sandboxes() { + let session = login_identity(ADMIN).await; + assert_allowed( + &session, + &["sandbox", "list", "--output", "json"], + "list sandboxes", + ) + .await; +} + +#[tokio::test] +async fn user_can_list_sandboxes() { + const WORKSPACE: &str = "oidc-user-list-sb"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + assert_workspace_allowed( + &user, + WORKSPACE, + &["sandbox", "list", "--output", "json"], + "list sandboxes", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn admin_can_create_sandbox() { + let session = login_identity(ADMIN).await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_create_sandbox(&session, "default", "oidc-admin-create").await; +} + +#[tokio::test] +async fn user_can_create_sandbox() { + const WORKSPACE: &str = "oidc-user-create"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_create_sandbox(&user, WORKSPACE, "oidc-user-create").await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn admin_can_delete_sandbox() { + let session = login_identity(ADMIN).await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_delete_sandbox(&session, "default", "oidc-admin-delete").await; +} + +#[tokio::test] +async fn user_can_delete_sandbox() { + const WORKSPACE: &str = "oidc-user-delete"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_delete_sandbox(&user, WORKSPACE, "oidc-user-delete").await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn admin_can_inspect_gateway() { + let session = login_identity(ADMIN).await; + let output = assert_allowed(&session, &["gateway", "info"], "inspect gateway info").await; + let info = combined_output(&output); + let expected_driver = + std::env::var("OPENSHELL_E2E_DRIVER").expect("OIDC E2E requires OPENSHELL_E2E_DRIVER"); + assert!( + info.to_ascii_lowercase() + .contains(&expected_driver.to_ascii_lowercase()), + "gateway info should report the {expected_driver} compute driver: {info}" + ); +} + +#[tokio::test] +async fn user_cannot_inspect_gateway() { + let session = login_identity(USER).await; + let output = run_session_cli(&session, &["gateway", "info"]).await; + let denied = combined_output(&output); + assert!( + !output.status.success(), + "user accessed gateway info:\n{denied}" + ); + assert!( + denied.contains("requires admin privileges"), + "gateway-info denial should explain that admin privileges are required:\n{denied}" + ); + assert_admin_role_denial(&output, "inspect gateway info"); +} + +#[tokio::test] +async fn admin_can_list_providers() { + let session = login_identity(ADMIN).await; + assert_allowed( + &session, + &["provider", "list", "--output", "json"], + "list providers", + ) + .await; +} + +#[tokio::test] +async fn user_can_list_providers() { + const WORKSPACE: &str = "oidc-user-list-pr"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + assert_workspace_allowed( + &user, + WORKSPACE, + &["provider", "list", "--output", "json"], + "list providers", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn admin_can_manage_provider() { + const PROVIDER: &str = "oidc-pkce-admin-provider"; + let session = login_identity(ADMIN).await; + + assert_allowed( + &session, + &[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + "create a provider", + ) + .await; + + let get = assert_allowed(&session, &["provider", "get", PROVIDER], "read a provider").await; + assert!( + combined_output(&get).contains(PROVIDER), + "provider get output should contain the created provider:\n{}", + combined_output(&get) + ); + + assert_allowed( + &session, + &["provider", "delete", PROVIDER], + "delete a provider", + ) + .await; +} + +#[tokio::test] +async fn user_cannot_create_provider() { + const WORKSPACE: &str = "oidc-user-no-create"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + let output = run_workspace_cli( + &user, + WORKSPACE, + &[ + "provider", + "create", + "--name", + "oidc-pkce-user-provider", + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + ) + .await; + assert_workspace_admin_denial(&output, "create a provider"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn user_cannot_delete_provider() { + const PROVIDER: &str = "oidc-pkce-user-delete-target"; + const WORKSPACE: &str = "oidc-user-no-delete"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + assert_workspace_allowed( + &admin, + WORKSPACE, + &[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + "create the provider deletion target", + ) + .await; + + let denied = run_workspace_cli(&user, WORKSPACE, &["provider", "delete", PROVIDER]).await; + assert_workspace_admin_denial(&denied, "delete a provider"); + + assert_workspace_allowed( + &admin, + WORKSPACE, + &["provider", "delete", PROVIDER], + "clean up the provider deletion target", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn admin_can_create_workspace() { + const WORKSPACE: &str = "oidc-admin-create"; + let admin = login_identity(ADMIN).await; + let _ = run_session_cli(&admin, &["workspace", "delete", WORKSPACE]).await; + assert_allowed( + &admin, + &["workspace", "create", "--name", WORKSPACE], + "create a workspace", + ) + .await; + let get = assert_allowed( + &admin, + &["workspace", "get", WORKSPACE], + "read the created workspace", + ) + .await; + assert!(combined_output(&get).contains(WORKSPACE)); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn user_cannot_create_workspace() { + let user = login_identity(USER).await; + let denied = run_session_cli( + &user, + &["workspace", "create", "--name", "oidc-user-denied"], + ) + .await; + assert_admin_role_denial(&denied, "create a workspace"); +} + +#[tokio::test] +async fn admin_can_delete_workspace() { + const WORKSPACE: &str = "oidc-admin-delete"; + let admin = login_identity(ADMIN).await; + let _ = run_session_cli(&admin, &["workspace", "delete", WORKSPACE]).await; + assert_allowed( + &admin, + &["workspace", "create", "--name", WORKSPACE], + "create a workspace deletion target", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn user_cannot_delete_workspace() { + const WORKSPACE: &str = "oidc-user-del-deny"; + let admin = login_identity(ADMIN).await; + let user = login_identity(USER).await; + let _ = run_session_cli(&admin, &["workspace", "delete", WORKSPACE]).await; + assert_allowed( + &admin, + &["workspace", "create", "--name", WORKSPACE], + "create a workspace deletion target", + ) + .await; + let denied = run_session_cli(&user, &["workspace", "delete", WORKSPACE]).await; + assert_admin_role_denial(&denied, "delete a workspace"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_user_can_read_workspace() { + const WORKSPACE: &str = "oidc-ws-user-read"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + + let get = assert_allowed( + &user, + &["workspace", "get", WORKSPACE], + "read a member workspace", + ) + .await; + let list = assert_allowed( + &user, + &["workspace", "list", "--output", "json"], + "list member workspaces", + ) + .await; + let members = assert_allowed( + &user, + &["workspace", "member", "list", "--workspace", WORKSPACE], + "list workspace members", + ) + .await; + assert!(combined_output(&get).contains(WORKSPACE)); + assert!(combined_output(&list).contains(WORKSPACE)); + assert!(combined_output(&members).contains(&user.subject)); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_user_cannot_manage_members() { + const WORKSPACE: &str = "oidc-ws-user-deny"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + let denied = run_session_cli( + &user, + &[ + "workspace", + "member", + "add", + "--workspace", + WORKSPACE, + "--subject", + "oidc-fake-member", + "--role", + "user", + ], + ) + .await; + assert_workspace_admin_denial(&denied, "add a workspace member"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_read_workspace() { + const WORKSPACE: &str = "oidc-wsa-read"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + + let get = assert_allowed( + &user, + &["workspace", "get", WORKSPACE], + "read an administered workspace", + ) + .await; + assert!(combined_output(&get).contains(WORKSPACE)); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_create_sandbox() { + const WORKSPACE: &str = "oidc-wsa-create-sb"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_create_sandbox(&user, WORKSPACE, "oidc-wsa-create").await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_delete_sandbox() { + const WORKSPACE: &str = "oidc-wsa-delete-sb"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; + assert_can_delete_sandbox(&user, WORKSPACE, "oidc-wsa-delete").await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_create_provider() { + const WORKSPACE: &str = "oidc-wsa-create-pr"; + const PROVIDER: &str = "oidc-wsa-create-provider"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + assert_workspace_allowed( + &user, + WORKSPACE, + &[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + "create a provider as workspace admin", + ) + .await; + + assert_workspace_allowed( + &admin, + WORKSPACE, + &["provider", "delete", PROVIDER], + "clean up the provider created by a workspace admin", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_delete_provider() { + const WORKSPACE: &str = "oidc-wsa-delete-pr"; + const PROVIDER: &str = "oidc-wsa-delete-provider"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + assert_workspace_allowed( + &admin, + WORKSPACE, + &[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + "create the provider deletion target", + ) + .await; + assert_workspace_allowed( + &user, + WORKSPACE, + &["provider", "delete", PROVIDER], + "delete a provider as workspace admin", + ) + .await; + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_add_user_member() { + const WORKSPACE: &str = "oidc-wsa-add-user"; + let workspace_admin = login_identity(USER).await; + let user = login_identity(USER_B).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &workspace_admin, WORKSPACE, "admin").await; + + assert_allowed( + &workspace_admin, + &[ + "workspace", + "member", + "add", + "--workspace", + WORKSPACE, + "--subject", + &user.subject, + "--role", + "user", + ], + "add a standard workspace member", + ) + .await; + let members = assert_allowed( + &workspace_admin, + &["workspace", "member", "list", "--workspace", WORKSPACE], + "list workspace members after adding one", + ) + .await; + assert!(combined_output(&members).contains(&user.subject)); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_can_remove_user_member() { + const WORKSPACE: &str = "oidc-wsa-rm-user"; + let workspace_admin = login_identity(USER).await; + let user = login_identity(USER_B).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &workspace_admin, WORKSPACE, "admin").await; + assert_allowed( + &admin, + &[ + "workspace", + "member", + "add", + "--workspace", + WORKSPACE, + "--subject", + &user.subject, + "--role", + "user", + ], + "create the workspace member removal target", + ) + .await; + + assert_allowed( + &workspace_admin, + &[ + "workspace", + "member", + "remove", + "--workspace", + WORKSPACE, + "--subject", + &user.subject, + ], + "remove a standard workspace member", + ) + .await; + let denied = run_session_cli(&user, &["workspace", "get", WORKSPACE]).await; + assert_non_member_denial(&denied, "read a workspace after removal by its admin"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_grant_admin() { + const WORKSPACE: &str = "oidc-ws-admin-deny"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + let denied = run_session_cli( + &user, + &[ + "workspace", + "member", + "add", + "--workspace", + WORKSPACE, + "--subject", + "oidc-fake-admin", + "--role", + "admin", + ], + ) + .await; + assert_platform_admin_denial(&denied, "grant workspace admin"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_create_workspace() { + const WORKSPACE: &str = "oidc-wsa-no-create"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + + let denied = + run_session_cli(&user, &["workspace", "create", "--name", "oidc-wsa-denied"]).await; + assert_admin_role_denial(&denied, "create a workspace as workspace admin"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_delete_workspace() { + const WORKSPACE: &str = "oidc-wsa-no-delete"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + + let denied = run_session_cli(&user, &["workspace", "delete", WORKSPACE]).await; + assert_admin_role_denial(&denied, "delete an administered workspace"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_inspect_gateway() { + const WORKSPACE: &str = "oidc-wsa-no-gw-info"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "admin").await; + + let denied = run_workspace_cli(&user, WORKSPACE, &["gateway", "info"]).await; + assert_admin_role_denial(&denied, "inspect gateway info as workspace admin"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_read_another_workspace() { + const WORKSPACE_A: &str = "oidc-wsa-xread-a"; + const WORKSPACE_B: &str = "oidc-wsa-xread-b"; + let (admin, workspace_admin, _user_b) = + prepare_isolated_workspaces_with_admin(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_session_cli(&workspace_admin, &["workspace", "get", WORKSPACE_B]).await; + assert_non_member_denial(&denied, "read another workspace as workspace admin"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_manage_another_workspace_members() { + const WORKSPACE_A: &str = "oidc-wsa-xmem-a"; + const WORKSPACE_B: &str = "oidc-wsa-xmem-b"; + let (admin, workspace_admin, _user_b) = + prepare_isolated_workspaces_with_admin(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_session_cli( + &workspace_admin, + &[ + "workspace", + "member", + "add", + "--workspace", + WORKSPACE_B, + "--subject", + "oidc-fake-member", + "--role", + "user", + ], + ) + .await; + assert_non_member_denial( + &denied, + "manage another workspace's members as workspace admin", + ); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_admin_cannot_manage_another_workspace_providers() { + const WORKSPACE_A: &str = "oidc-wsa-xprov-a"; + const WORKSPACE_B: &str = "oidc-wsa-xprov-b"; + let (admin, workspace_admin, _user_b) = + prepare_isolated_workspaces_with_admin(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_workspace_cli( + &workspace_admin, + WORKSPACE_B, + &[ + "provider", + "create", + "--name", + "oidc-wsa-xprovider", + "--type", + "generic", + "--credential", + "TOKEN=e2e-test-value", + ], + ) + .await; + assert_non_member_denial( + &denied, + "manage another workspace's providers as workspace admin", + ); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn membership_removal_revokes_workspace_access() { + const WORKSPACE: &str = "oidc-ws-revoke"; + let user = login_identity(USER).await; + let admin = login_identity(ADMIN).await; + prepare_workspace(&admin, &user, WORKSPACE, "user").await; + assert_allowed( + &admin, + &[ + "workspace", + "member", + "remove", + "--workspace", + WORKSPACE, + "--subject", + &user.subject, + ], + "remove a workspace member", + ) + .await; + let denied = run_session_cli(&user, &["workspace", "get", WORKSPACE]).await; + assert_non_member_denial(&denied, "read a workspace after membership removal"); + delete_workspace(&admin, WORKSPACE).await; +} + +#[tokio::test] +async fn workspace_user_cannot_read_another_users_workspace() { + const WORKSPACE_A: &str = "oidc-xread-a"; + const WORKSPACE_B: &str = "oidc-xread-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_session_cli(&user_a, &["workspace", "get", WORKSPACE_B]).await; + assert_non_member_denial(&denied, "read another user's workspace"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn second_workspace_user_cannot_read_first_users_workspace() { + const WORKSPACE_A: &str = "oidc-xread2-a"; + const WORKSPACE_B: &str = "oidc-xread2-b"; + let (admin, _user_a, user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_session_cli(&user_b, &["workspace", "get", WORKSPACE_A]).await; + assert_non_member_denial(&denied, "read another user's workspace"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_list_hides_another_users_workspace() { + const WORKSPACE_A: &str = "oidc-xlist-a"; + const WORKSPACE_B: &str = "oidc-xlist-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let listed = assert_allowed( + &user_a, + &["workspace", "list", "--output", "json"], + "list visible workspaces", + ) + .await; + let output = combined_output(&listed); + assert!( + output.contains(WORKSPACE_A), + "workspace list should contain the caller's workspace:\n{output}" + ); + assert!( + !output.contains(WORKSPACE_B), + "workspace list exposed another user's workspace:\n{output}" + ); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_user_cannot_list_another_workspace_sandboxes() { + const WORKSPACE_A: &str = "oidc-xsbox-a"; + const WORKSPACE_B: &str = "oidc-xsbox-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_workspace_cli( + &user_a, + WORKSPACE_B, + &["sandbox", "list", "--output", "json"], + ) + .await; + assert_non_member_denial(&denied, "list another workspace's sandboxes"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_user_cannot_create_sandbox_in_another_workspace() { + const WORKSPACE_A: &str = "oidc-xcreate-a"; + const WORKSPACE_B: &str = "oidc-xcreate-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_workspace_cli( + &user_a, + WORKSPACE_B, + &[ + "sandbox", + "create", + "--name", + "oidc-xcreate-denied", + "--no-tty", + "--", + "echo", + "denied", + ], + ) + .await; + assert_non_member_denial(&denied, "create a sandbox in another workspace"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_user_cannot_list_another_workspace_providers() { + const WORKSPACE_A: &str = "oidc-xprov-a"; + const WORKSPACE_B: &str = "oidc-xprov-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_workspace_cli( + &user_a, + WORKSPACE_B, + &["provider", "list", "--output", "json"], + ) + .await; + assert_non_member_denial(&denied, "list another workspace's providers"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +#[tokio::test] +async fn workspace_user_cannot_list_another_workspace_members() { + const WORKSPACE_A: &str = "oidc-xmember-a"; + const WORKSPACE_B: &str = "oidc-xmember-b"; + let (admin, user_a, _user_b) = prepare_isolated_workspaces(WORKSPACE_A, WORKSPACE_B).await; + + let denied = run_session_cli( + &user_a, + &["workspace", "member", "list", "--workspace", WORKSPACE_B], + ) + .await; + assert_non_member_denial(&denied, "list another workspace's members"); + + delete_workspace(&admin, WORKSPACE_B).await; + delete_workspace(&admin, WORKSPACE_A).await; +} + +async fn login_identity(identity: IdentityScenario) -> LoginSession { + let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER") + .unwrap_or_else(|_| "http://localhost:8180/realms/openshell".to_string()); + let gateway_endpoint = std::env::var("OPENSHELL_E2E_OIDC_GATEWAY_ENDPOINT") + .expect("OIDC E2E requires a live gateway endpoint"); + let temp = tempfile::tempdir().expect("create isolated test directory"); + let fake_bin = temp.path().join("bin"); + std::fs::create_dir(&fake_bin).expect("create fake bin directory"); + let browser_url_file = temp.path().join("browser-url"); + install_xdg_open_recorder(&fake_bin); + + let path = prepend_path(&fake_bin); + let mut cli = openshell_cmd(); + cli.args([ + "gateway", + "add", + &gateway_endpoint, + "--name", + identity.gateway_name, + "--local", + "--oidc-issuer", + &issuer, + "--oidc-scopes", + "profile email openshell:all", + ]) + .env("XDG_CONFIG_HOME", temp.path()) + .env("HOME", temp.path()) + .env("PATH", path) + .env("OPENSHELL_E2E_BROWSER_URL_FILE", &browser_url_file) + .env_remove("OPENSHELL_GATEWAY") + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .env_remove("OPENSHELL_NO_BROWSER") + .env_remove("OPENSHELL_OIDC_CLIENT_SECRET") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cli.spawn().expect("start openshell PKCE login"); + let authorization_url = wait_for_browser_url(&browser_url_file).await; + let redirect_uri = assert_pkce_authorization_url(&authorization_url, &issuer); + + let cookie_jar = temp.path().join("keycloak-cookies"); + let login_page = curl_get(&authorization_url, &cookie_jar).await; + let login_action = extract_login_action(&login_page); + let callback_page = curl_login( + &login_action, + &cookie_jar, + identity.username, + identity.password, + ) + .await; + assert!( + callback_page.contains("Authentication successful"), + "loopback callback did not return its success page:\n{callback_page}" + ); + + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .expect("openshell did not finish after receiving the OIDC callback") + .expect("wait for openshell PKCE login"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.status.success(), + "openshell PKCE login failed:\n{combined}" + ); + assert!( + combined.contains("Authenticated successfully"), + "missing successful authentication message:\n{combined}" + ); + + let subject = assert_persisted_login( + temp.path(), + &issuer, + &redirect_uri, + identity.gateway_name, + identity.username, + identity.expected_role, + ); + + LoginSession { + config_home: temp, + identity, + subject, + } +} + +fn install_xdg_open_recorder(bin_dir: &Path) { + let script = bin_dir.join("xdg-open"); + std::fs::write( + &script, + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$1\" > \"$OPENSHELL_E2E_BROWSER_URL_FILE\"\n", + ) + .expect("write xdg-open recorder"); + std::fs::set_permissions(&script, Permissions::from_mode(0o755)) + .expect("make xdg-open recorder executable"); +} + +fn prepend_path(bin_dir: &Path) -> OsString { + let current = std::env::var_os("PATH").unwrap_or_default(); + std::env::join_paths( + std::iter::once(bin_dir.to_path_buf()).chain(std::env::split_paths(¤t)), + ) + .expect("construct PATH with xdg-open recorder") +} + +async fn wait_for_browser_url(path: &Path) -> String { + for _ in 0..200 { + if let Ok(contents) = tokio::fs::read_to_string(path).await { + let url = contents.trim(); + if !url.is_empty() { + return url.to_string(); + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!( + "xdg-open did not receive an authorization URL within 10 seconds ({})", + path.display() + ); +} + +fn assert_pkce_authorization_url(authorization_url: &str, issuer: &str) -> String { + let url = Url::parse(authorization_url).expect("authorization URL is valid"); + let expected_path = format!( + "{}/protocol/openid-connect/auth", + Url::parse(issuer) + .expect("issuer URL is valid") + .path() + .trim_end_matches('/') + ); + assert_eq!(url.path(), expected_path); + + let params: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!( + params.get("response_type").map(String::as_str), + Some("code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("openshell-cli") + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + let challenge = params + .get("code_challenge") + .expect("authorization URL has a PKCE challenge"); + assert_eq!(challenge.len(), 43, "S256 challenge is base64url encoded"); + assert!( + challenge + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), + "PKCE challenge must use unpadded base64url" + ); + assert!( + params.get("state").is_some_and(|state| !state.is_empty()), + "authorization URL must contain CSRF state" + ); + + let scopes: Vec<_> = params + .get("scope") + .expect("authorization URL has scopes") + .split_whitespace() + .collect(); + for expected in ["openid", "profile", "email", "openshell:all"] { + assert!(scopes.contains(&expected), "missing OIDC scope {expected}"); + } + + let redirect_uri = params + .get("redirect_uri") + .expect("authorization URL has a redirect URI"); + let redirect = Url::parse(redirect_uri).expect("redirect URI is valid"); + assert_eq!(redirect.scheme(), "http"); + assert_eq!(redirect.host_str(), Some("127.0.0.1")); + assert!( + redirect.port().is_some(), + "redirect URI has a callback port" + ); + assert_eq!(redirect.path(), "/callback"); + redirect_uri.clone() +} + +async fn curl_get(url: &str, cookie_jar: &Path) -> String { + let output = Command::new("curl") + .args(["--fail", "--silent", "--show-error", "--cookie-jar"]) + .arg(cookie_jar) + .arg(url) + .output() + .await + .expect("run curl for Keycloak login page"); + assert!( + output.status.success(), + "failed to load Keycloak login page: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("Keycloak login page is UTF-8") +} + +async fn curl_login(action: &str, cookie_jar: &Path, username: &str, password: &str) -> String { + let output = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--location", + "--cookie", + ]) + .arg(cookie_jar) + .arg("--cookie-jar") + .arg(cookie_jar) + .arg("--data-urlencode") + .arg(format!("username={username}")) + .arg("--data-urlencode") + .arg(format!("password={password}")) + .arg("--data-urlencode") + .arg("credentialId=") + .arg(action) + .output() + .await + .expect("run curl for Keycloak credentials submission"); + assert!( + output.status.success(), + "Keycloak login submission failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("OIDC callback page is UTF-8") +} + +fn extract_login_action(html: &str) -> String { + let form_id = html + .find("id=\"kc-form-login\"") + .expect("Keycloak page has the login form"); + let form_start = html[..form_id] + .rfind("') + .expect("Keycloak login form start tag is closed"); + let form = &html[form_start..form_end]; + let action_start = form + .find("action=\"") + .map(|index| index + "action=\"".len()) + .expect("Keycloak login form has an action"); + let action_end = action_start + + form[action_start..] + .find('"') + .expect("Keycloak login action is quoted"); + form[action_start..action_end] + .replace("&", "&") + .replace("&", "&") +} + +fn assert_persisted_login( + config_home: &Path, + issuer: &str, + redirect_uri: &str, + gateway_name: &str, + username: &str, + expected_role: &str, +) -> String { + let gateway_dir = config_home + .join("openshell") + .join("gateways") + .join(gateway_name); + let metadata: Value = read_json(&gateway_dir.join("metadata.json")); + assert_eq!(metadata["auth_mode"], "oidc"); + assert_eq!(metadata["oidc_issuer"], issuer); + assert_eq!(metadata["oidc_client_id"], "openshell-cli"); + assert_eq!(metadata["oidc_scopes"], "profile email openshell:all"); + + let token: Value = read_json(&gateway_dir.join("oidc_token.json")); + let access_token = token["access_token"] + .as_str() + .expect("stored access token is a string"); + assert!(!access_token.is_empty()); + assert!( + token["refresh_token"] + .as_str() + .is_some_and(|refresh| !refresh.is_empty()), + "browser flow should persist a refresh token" + ); + assert_eq!(token["issuer"], issuer); + assert_eq!(token["client_id"], "openshell-cli"); + + let claims = decode_jwt_claims(access_token); + assert!(jwt_audience_contains(&claims["aud"], "openshell-cli")); + assert_eq!(claims["azp"], "openshell-cli"); + assert_eq!(claims["preferred_username"], username); + let subject = claims["sub"] + .as_str() + .filter(|subject| !subject.is_empty()) + .expect("access token should contain a non-empty subject") + .to_string(); + assert!( + claims["realm_access"]["roles"] + .as_array() + .is_some_and(|roles| roles.iter().any(|role| role == expected_role)), + "access token should contain the {expected_role} realm role" + ); + + let redirect = Url::parse(redirect_uri).expect("saved redirect URI remains valid"); + assert_eq!(redirect.host_str(), Some("127.0.0.1")); + subject +} + +async fn assert_allowed(session: &LoginSession, args: &[&str], action: &str) -> Output { + let output = run_session_cli(session, args).await; + assert!( + output.status.success(), + "{} should be allowed to {action}:\n{}", + session.identity.username, + combined_output(&output) + ); + output +} + +async fn assert_workspace_allowed( + session: &LoginSession, + workspace: &str, + args: &[&str], + action: &str, +) -> Output { + let output = run_workspace_cli(session, workspace, args).await; + assert!( + output.status.success(), + "{} should be allowed to {action} in workspace {workspace}:\n{}", + session.identity.username, + combined_output(&output) + ); + output +} + +async fn prepare_workspace( + admin: &LoginSession, + member: &LoginSession, + workspace: &str, + role: &str, +) { + let _ = run_session_cli(admin, &["workspace", "delete", workspace]).await; + assert_allowed( + admin, + &["workspace", "create", "--name", workspace], + "create a workspace fixture", + ) + .await; + assert_allowed( + admin, + &[ + "workspace", + "member", + "add", + "--workspace", + workspace, + "--subject", + &member.subject, + "--role", + role, + ], + "add a workspace member", + ) + .await; +} + +async fn prepare_isolated_workspaces( + workspace_a: &str, + workspace_b: &str, +) -> (LoginSession, LoginSession, LoginSession) { + let admin = login_identity(ADMIN).await; + let user_a = login_identity(USER).await; + let user_b = login_identity(USER_B).await; + prepare_workspace(&admin, &user_a, workspace_a, "user").await; + prepare_workspace(&admin, &user_b, workspace_b, "user").await; + (admin, user_a, user_b) +} + +async fn prepare_isolated_workspaces_with_admin( + workspace_a: &str, + workspace_b: &str, +) -> (LoginSession, LoginSession, LoginSession) { + let admin = login_identity(ADMIN).await; + let workspace_admin = login_identity(USER).await; + let user_b = login_identity(USER_B).await; + prepare_workspace(&admin, &workspace_admin, workspace_a, "admin").await; + prepare_workspace(&admin, &user_b, workspace_b, "user").await; + (admin, workspace_admin, user_b) +} + +async fn delete_workspace(admin: &LoginSession, workspace: &str) { + assert_allowed( + admin, + &["workspace", "delete", workspace], + "delete a workspace", + ) + .await; +} + +async fn assert_can_create_sandbox(session: &LoginSession, workspace: &str, sandbox_name: &str) { + let marker = format!("{sandbox_name}-ready"); + let create = run_workspace_cli( + session, + workspace, + &[ + "sandbox", + "create", + "--name", + sandbox_name, + "--no-tty", + "--", + "echo", + &marker, + ], + ) + .await; + let create_output = combined_output(&create); + + if !create.status.success() { + let _ = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + panic!( + "{} should be allowed to create sandbox {sandbox_name}:\n{create_output}", + session.identity.username + ); + } + + let list = + run_workspace_cli(session, workspace, &["sandbox", "list", "--output", "json"]).await; + let list_output = combined_output(&list); + let cleanup = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + + assert!( + create_output.contains(&marker), + "sandbox command output should contain {marker}:\n{create_output}" + ); + assert!( + list.status.success() && list_output.contains(sandbox_name), + "created sandbox {sandbox_name} should appear in the sandbox list:\n{list_output}" + ); + assert!( + cleanup.status.success(), + "failed to clean up created sandbox {sandbox_name}:\n{}", + combined_output(&cleanup) + ); +} + +async fn assert_can_delete_sandbox(session: &LoginSession, workspace: &str, sandbox_name: &str) { + let marker = format!("{sandbox_name}-ready"); + let create = run_workspace_cli( + session, + workspace, + &[ + "sandbox", + "create", + "--name", + sandbox_name, + "--no-tty", + "--", + "echo", + &marker, + ], + ) + .await; + let create_output = combined_output(&create); + if !create.status.success() { + let _ = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + panic!("failed to create sandbox deletion target {sandbox_name}:\n{create_output}"); + } + + let delete = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + let delete_output = combined_output(&delete); + if !delete.status.success() { + let _ = run_workspace_cli(session, workspace, &["sandbox", "delete", sandbox_name]).await; + panic!( + "{} should be allowed to delete sandbox {sandbox_name}:\n{delete_output}", + session.identity.username + ); + } + + if let Err(last_list) = wait_for_sandbox_absence(session, workspace, sandbox_name).await { + panic!( + "deleted sandbox {sandbox_name} should disappear from the sandbox list:\n{last_list}" + ); + } +} + +async fn wait_for_sandbox_absence( + session: &LoginSession, + workspace: &str, + sandbox_name: &str, +) -> Result<(), String> { + const TIMEOUT: Duration = Duration::from_secs(30); + const POLL_INTERVAL: Duration = Duration::from_millis(250); + + let deadline = Instant::now() + TIMEOUT; + loop { + let list = + run_workspace_cli(session, workspace, &["sandbox", "list", "--output", "json"]).await; + let list_output = combined_output(&list); + if !list.status.success() { + return Err(list_output); + } + + let present = list_output.contains(sandbox_name); + if !present { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(list_output); + } + + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +async fn run_session_cli(session: &LoginSession, args: &[&str]) -> Output { + let mut command_args = Vec::with_capacity(args.len() + 2); + command_args.extend(["--gateway", session.identity.gateway_name]); + command_args.extend_from_slice(args); + run_cli(session.config_home.path(), &command_args).await +} + +async fn run_workspace_cli(session: &LoginSession, workspace: &str, args: &[&str]) -> Output { + let mut command_args = Vec::with_capacity(args.len() + 4); + command_args.extend([ + "--gateway", + session.identity.gateway_name, + "--workspace", + workspace, + ]); + command_args.extend_from_slice(args); + run_cli(session.config_home.path(), &command_args).await +} + +fn assert_admin_role_denial(output: &Output, action: &str) { + let denied = combined_output(output); + let compact_denial: String = denied + .chars() + .filter(|character| !character.is_whitespace() && *character != '│') + .collect(); + assert!( + !output.status.success() && compact_denial.contains("openshell-admin"), + "standard user unexpectedly authorized to {action}, or denial omitted the admin role:\n{denied}" + ); +} + +fn assert_workspace_admin_denial(output: &Output, action: &str) { + let denied = combined_output(output); + let normalized = denied.to_ascii_lowercase(); + assert!( + !output.status.success() + && normalized.contains("workspace role") + && normalized.contains("admin"), + "workspace user unexpectedly authorized to {action}, or denial omitted the required workspace role:\n{denied}" + ); +} + +fn assert_platform_admin_denial(output: &Output, action: &str) { + let denied = combined_output(output); + assert!( + !output.status.success() && denied.to_ascii_lowercase().contains("platform admin"), + "non-platform-admin unexpectedly authorized to {action}, or denial omitted the required platform role:\n{denied}" + ); +} + +fn assert_non_member_denial(output: &Output, action: &str) { + let denied = combined_output(output); + assert!( + !output.status.success() + && denied + .to_ascii_lowercase() + .contains("not a member of workspace"), + "non-member unexpectedly authorized to {action}, or denial omitted membership context:\n{denied}" + ); +} + +async fn run_cli(config_home: &Path, args: &[&str]) -> Output { + openshell_cmd() + .arg("--gateway-insecure") + .args(args) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) + .env("OPENSHELL_GATEWAY_INSECURE", "true") + .env_remove("OPENSHELL_GATEWAY") + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .env_remove("OPENSHELL_OIDC_CLIENT_SECRET") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .expect("run openshell authorization action") +} + +fn combined_output(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn read_json(path: &Path) -> Value { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("parse {} as JSON: {error}", path.display())) +} + +fn decode_jwt_claims(token: &str) -> Value { + let payload = token.split('.').nth(1).expect("access token is a JWT"); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .expect("decode JWT claims"); + serde_json::from_slice(&bytes).expect("parse JWT claims") +} + +fn jwt_audience_contains(audience: &Value, expected: &str) -> bool { + audience.as_str() == Some(expected) + || audience + .as_array() + .is_some_and(|values| values.iter().any(|value| value == expected)) +} diff --git a/e2e/support/gateway-common.sh b/e2e/support/gateway-common.sh index 6b9e6a0956..e7fa6d15f8 100644 --- a/e2e/support/gateway-common.sh +++ b/e2e/support/gateway-common.sh @@ -112,18 +112,25 @@ e2e_register_mtls_gateway() { local endpoint=$3 local port=$4 local pki_dir=$5 + local oidc_issuer="${6:-}" local gateway_config_dir="${config_home}/openshell/gateways/${name}" mkdir -p "${gateway_config_dir}/mtls" cp "${pki_dir}/ca.crt" "${gateway_config_dir}/mtls/ca.crt" cp "${pki_dir}/client/tls.crt" "${gateway_config_dir}/mtls/tls.crt" cp "${pki_dir}/client/tls.key" "${gateway_config_dir}/mtls/tls.key" + + local oidc_line="" + if [ -n "${oidc_issuer}" ]; then + oidc_line="$(printf ',\n "oidc_issuer": "%s"' "${oidc_issuer}")" + fi + cat >"${gateway_config_dir}/metadata.json" <"${config_home}/openshell/active_gateway" @@ -167,6 +174,20 @@ e2e_write_gateway_mtls_auth_config() { printf 'enabled = true\n\n' } +e2e_write_gateway_oidc_config() { + local issuer=$1 + local scopes_claim="${2:-scope}" + + printf '[openshell.gateway.oidc]\n' + printf 'issuer = %s\n' "$(e2e_toml_string "${issuer}")" + printf 'audience = "openshell-cli"\n' + printf 'jwks_ttl_secs = 60\n' + printf 'roles_claim = "realm_access.roles"\n' + printf 'admin_role = "openshell-admin"\n' + printf 'user_role = "openshell-user"\n' + printf 'scopes_claim = %s\n\n' "$(e2e_toml_string "${scopes_claim}")" +} + e2e_build_gateway_binaries() { local root=$1 local target_var=$2 diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 64062b74d6..76fc837791 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -114,6 +114,13 @@ DOCKER_NETWORK_NAME="" DOCKER_NETWORK_CONNECTED_CONTAINER="" DOCKER_NETWORK_MANAGED=0 GPU_MODE="${OPENSHELL_E2E_DOCKER_GPU:-0}" +OIDC_MODE="${OPENSHELL_E2E_OIDC_GATEWAY:-0}" +OIDC_ISSUER="${OPENSHELL_E2E_OIDC_ISSUER:-}" + +if [ "${OIDC_MODE}" = "1" ] && [ -z "${OIDC_ISSUER}" ]; then + echo "ERROR: OPENSHELL_E2E_OIDC_ISSUER is required when OPENSHELL_E2E_OIDC_GATEWAY=1" >&2 + exit 2 +fi # Isolate CLI/SDK gateway metadata from the developer's real config. export XDG_CONFIG_HOME="${WORKDIR}/config" @@ -479,7 +486,12 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" printf '[openshell]\nversion = 1\n\n' printf '[openshell.gateway]\nlog_level = "info"\n\n' e2e_write_gateway_jwt_config "${JWT_DIR}" "openshell-e2e-docker-${HOST_PORT}" - e2e_write_gateway_mtls_auth_config + if [ "${OIDC_MODE}" != "1" ]; then + e2e_write_gateway_mtls_auth_config + if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then + e2e_write_gateway_oidc_config "${OPENSHELL_OIDC_ISSUER}" + fi + fi printf '[openshell.drivers.docker]\n' printf 'sandbox_namespace = %s\n' "$(toml_string "${E2E_NAMESPACE}")" printf 'network_name = %s\n' "$(toml_string "${DOCKER_NETWORK_NAME}")" @@ -503,10 +515,21 @@ GATEWAY_ARGS=( --drivers docker --tls-cert "${PKI_DIR}/server/tls.crt" --tls-key "${PKI_DIR}/server/tls.key" - --tls-client-ca "${PKI_DIR}/ca.crt" --db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc" ) +if [ "${OIDC_MODE}" = "1" ]; then + GATEWAY_ARGS+=( + --oidc-issuer "${OIDC_ISSUER}" + --oidc-audience openshell-cli + --oidc-scopes-claim scope + ) +else + GATEWAY_ARGS+=( + --tls-client-ca "${PKI_DIR}/ca.crt" + ) +fi + e2e_write_gateway_args_file "${GATEWAY_ARGS_FILE}" "${GATEWAY_ARGS[@]}" e2e_export_gateway_restart_metadata \ "${GATEWAY_BIN}" \ @@ -520,16 +543,26 @@ printf '%s\n' "${GATEWAY_PID}" >"${GATEWAY_PID_FILE}" GATEWAY_NAME="openshell-e2e-docker-${HOST_PORT}" CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}" -e2e_register_mtls_gateway \ - "${XDG_CONFIG_HOME}" \ - "${GATEWAY_NAME}" \ - "${CLI_GATEWAY_ENDPOINT}" \ - "${HOST_PORT}" \ - "${PKI_DIR}" +if [ "${OIDC_MODE}" = "1" ]; then + export OPENSHELL_E2E_OIDC_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}" +else + e2e_register_mtls_gateway \ + "${XDG_CONFIG_HOME}" \ + "${GATEWAY_NAME}" \ + "${CLI_GATEWAY_ENDPOINT}" \ + "${HOST_PORT}" \ + "${PKI_DIR}" \ + "${OPENSHELL_OIDC_ISSUER:-}" +fi export OPENSHELL_GATEWAY="${GATEWAY_NAME}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-180}" +if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then + export OPENSHELL_E2E_OIDC=1 + export OPENSHELL_E2E_OIDC_SCOPES=1 +fi + echo "Waiting for gateway to become healthy..." elapsed=0 timeout=120 diff --git a/e2e/with-keycloak.sh b/e2e/with-keycloak.sh new file mode 100755 index 0000000000..571a25a123 --- /dev/null +++ b/e2e/with-keycloak.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Run a command against the local Keycloak OIDC fixture. An already-running +# fixture is preserved; a fixture started by this wrapper is removed on exit. + +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 [args...]" >&2 + exit 2 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KEYCLOAK_PORT="${KEYCLOAK_PORT:-8180}" + +if [ -n "${CONTAINER_RUNTIME:-}" ]; then + RUNTIME="$CONTAINER_RUNTIME" +elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + RUNTIME=docker +elif command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + RUNTIME=podman +else + echo "Error: no usable Docker or Podman runtime found" >&2 + exit 1 +fi + +STARTED_KEYCLOAK=0 +cleanup() { + local status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + echo "Keycloak logs from failed OIDC E2E run:" >&2 + "$RUNTIME" logs --tail 80 openshell-keycloak >&2 2>/dev/null || true + fi + if [ "$STARTED_KEYCLOAK" -eq 1 ]; then + CONTAINER_RUNTIME="$RUNTIME" KEYCLOAK_PORT="$KEYCLOAK_PORT" \ + "$ROOT_DIR/scripts/keycloak-dev.sh" stop + fi + exit "$status" +} +trap cleanup EXIT + +if ! CONTAINER_RUNTIME="$RUNTIME" KEYCLOAK_PORT="$KEYCLOAK_PORT" \ + "$ROOT_DIR/scripts/keycloak-dev.sh" status >/dev/null 2>&1; then + STARTED_KEYCLOAK=1 + CONTAINER_RUNTIME="$RUNTIME" KEYCLOAK_PORT="$KEYCLOAK_PORT" \ + "$ROOT_DIR/scripts/keycloak-dev.sh" start +fi + +export OPENSHELL_E2E_OIDC_ISSUER="${OPENSHELL_E2E_OIDC_ISSUER:-http://localhost:${KEYCLOAK_PORT}/realms/openshell}" +export OPENSHELL_E2E_OIDC_USERNAME="${OPENSHELL_E2E_OIDC_USERNAME:-admin@test}" +export OPENSHELL_E2E_OIDC_PASSWORD="${OPENSHELL_E2E_OIDC_PASSWORD:-admin}" +export OPENSHELL_E2E_OIDC_ROLE="${OPENSHELL_E2E_OIDC_ROLE:-openshell-admin}" + +"$@" diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index ba9179a841..514305246d 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -95,6 +95,13 @@ PODMAN_SERVICE_PID="" PODMAN_SERVICE_LOG="${WORKDIR}/podman-service.log" PODMAN_SOCKET="" GPU_MODE="${OPENSHELL_E2E_PODMAN_GPU:-0}" +OIDC_MODE="${OPENSHELL_E2E_OIDC_GATEWAY:-0}" +OIDC_ISSUER="${OPENSHELL_E2E_OIDC_ISSUER:-}" + +if [ "${OIDC_MODE}" = "1" ] && [ -z "${OIDC_ISSUER}" ]; then + echo "ERROR: OPENSHELL_E2E_OIDC_ISSUER is required when OPENSHELL_E2E_OIDC_GATEWAY=1" >&2 + exit 2 +fi # Isolate CLI/SDK gateway metadata from the developer's real config. export XDG_CONFIG_HOME="${WORKDIR}/config" @@ -409,7 +416,12 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}" { e2e_write_gateway_jwt_config "${JWT_DIR}" "openshell-e2e-podman-${HOST_PORT}" - e2e_write_gateway_mtls_auth_config + if [ "${OIDC_MODE}" != "1" ]; then + e2e_write_gateway_mtls_auth_config + if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then + e2e_write_gateway_oidc_config "${OPENSHELL_OIDC_ISSUER}" + fi + fi printf '\n[openshell.drivers.podman]\n' # The Podman driver scopes isolation by network rather than namespace. printf 'network_name = %s\n' "$(toml_string "${PODMAN_NETWORK_NAME}")" @@ -439,11 +451,22 @@ GATEWAY_ARGS=( --health-port "${HEALTH_PORT}" --tls-cert "${PKI_DIR}/server/tls.crt" --tls-key "${PKI_DIR}/server/tls.key" - --tls-client-ca "${PKI_DIR}/ca.crt" --db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc" --log-level info ) +if [ "${OIDC_MODE}" = "1" ]; then + GATEWAY_ARGS+=( + --oidc-issuer "${OIDC_ISSUER}" + --oidc-audience openshell-cli + --oidc-scopes-claim scope + ) +else + GATEWAY_ARGS+=( + --tls-client-ca "${PKI_DIR}/ca.crt" + ) +fi + e2e_write_gateway_args_file "${GATEWAY_ARGS_FILE}" "${GATEWAY_ARGS[@]}" e2e_export_gateway_restart_metadata \ "${GATEWAY_BIN}" \ @@ -458,17 +481,28 @@ GATEWAY_PID=$! printf '%s\n' "${GATEWAY_PID}" >"${GATEWAY_PID_FILE}" GATEWAY_NAME="openshell-e2e-podman-${HOST_PORT}" -CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}" -e2e_register_mtls_gateway \ - "${XDG_CONFIG_HOME}" \ - "${GATEWAY_NAME}" \ - "${CLI_GATEWAY_ENDPOINT}" \ - "${HOST_PORT}" \ - "${PKI_DIR}" +if [ "${OIDC_MODE}" = "1" ]; then + CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}" + export OPENSHELL_E2E_OIDC_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}" +else + CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}" + e2e_register_mtls_gateway \ + "${XDG_CONFIG_HOME}" \ + "${GATEWAY_NAME}" \ + "${CLI_GATEWAY_ENDPOINT}" \ + "${HOST_PORT}" \ + "${PKI_DIR}" \ + "${OPENSHELL_OIDC_ISSUER:-}" +fi export OPENSHELL_GATEWAY="${GATEWAY_NAME}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" +if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then + export OPENSHELL_E2E_OIDC=1 + export OPENSHELL_E2E_OIDC_SCOPES=1 +fi + echo "Waiting for gateway to become healthy..." elapsed=0 timeout=120 diff --git a/proto/inference.proto b/proto/inference.proto index f6fd2af0e0..a28d7149e5 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -12,23 +12,44 @@ import "options.proto"; service Inference { // Return the resolved inference route bundle for sandbox-local execution. rpc GetInferenceBundle(GetInferenceBundleRequest) - returns (GetInferenceBundleResponse); + returns (GetInferenceBundleResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Set the inference route for a workspace. // // This controls how requests sent to `inference.local` are routed // for sandboxes in the specified workspace. rpc SetInferenceRoute(SetInferenceRouteRequest) - returns (SetInferenceRouteResponse); + returns (SetInferenceRouteResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "inference:write" + workspace_role: "admin" + }; + } // Get the inference route for a workspace. rpc GetInferenceRoute(GetInferenceRouteRequest) - returns (GetInferenceRouteResponse); + returns (GetInferenceRouteResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "inference:read" + workspace_role: "user" + }; + } // Delete an inference route from a workspace. rpc DeleteInferenceRoute(DeleteInferenceRouteRequest) - returns (DeleteInferenceRouteResponse); - + returns (DeleteInferenceRouteResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "inference:write" + workspace_role: "admin" + }; + } } // Persisted inference route configuration. diff --git a/proto/openshell.proto b/proto/openshell.proto index 1b447a17e3..c16413ae86 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -20,152 +20,396 @@ import "sandbox.proto"; // resource messages before persisting or returning them to clients. service OpenShell { // Check the health of the service. - rpc Health(HealthRequest) returns (HealthResponse); + rpc Health(HealthRequest) returns (HealthResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "unauthenticated" + }; + } // Fetch elevated live gateway runtime metadata. - rpc GetGatewayInfo(GetGatewayInfoRequest) returns (GetGatewayInfoResponse); + rpc GetGatewayInfo(GetGatewayInfoRequest) returns (GetGatewayInfoResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:read" + global_role: "platform_admin" + }; + } // Create a new sandbox. - rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Fetch a sandbox by name. - rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // List sandboxes. - rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // List provider records attached to a sandbox. rpc ListSandboxProviders(ListSandboxProvidersRequest) - returns (ListSandboxProvidersResponse); + returns (ListSandboxProvidersResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // Attach a provider record to an existing sandbox. rpc AttachSandboxProvider(AttachSandboxProviderRequest) - returns (AttachSandboxProviderResponse); + returns (AttachSandboxProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Detach a provider record from an existing sandbox. rpc DetachSandboxProvider(DetachSandboxProviderRequest) - returns (DetachSandboxProviderResponse); + returns (DetachSandboxProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Delete a sandbox by name. - rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Create a short-lived SSH session for a sandbox. - rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Create or update a sandbox HTTP service endpoint for local routing. - rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse); + rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Fetch one sandbox HTTP service endpoint. - rpc GetService(GetServiceRequest) returns (ServiceEndpointResponse); + rpc GetService(GetServiceRequest) returns (ServiceEndpointResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // List sandbox HTTP service endpoints. - rpc ListServices(ListServicesRequest) returns (ListServicesResponse); + rpc ListServices(ListServicesRequest) returns (ListServicesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // Delete one sandbox HTTP service endpoint. - rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse); + rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Revoke a previously issued SSH session. - rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Execute a command in a ready sandbox and stream output. - rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. - rpc ForwardTcp(stream TcpForwardFrame) returns (stream TcpForwardFrame); + rpc ForwardTcp(stream TcpForwardFrame) returns (stream TcpForwardFrame) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Execute an interactive command with bidirectional stdin/stdout streaming. // The first client message MUST carry an ExecSandboxInput with the start // variant. Subsequent messages carry stdin bytes or window resize events. - rpc ExecSandboxInteractive(stream ExecSandboxInput) returns (stream ExecSandboxEvent); + rpc ExecSandboxInteractive(stream ExecSandboxInput) returns (stream ExecSandboxEvent) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } // Create a provider. - rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Fetch a provider by name. - rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + rpc GetProvider(GetProviderRequest) returns (ProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // List providers. - rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // List available provider type profiles. rpc ListProviderProfiles(ListProviderProfilesRequest) - returns (ListProviderProfilesResponse); + returns (ListProviderProfilesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // Fetch one provider type profile by id. rpc GetProviderProfile(GetProviderProfileRequest) - returns (ProviderProfileResponse); + returns (ProviderProfileResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // Import custom provider type profiles. rpc ImportProviderProfiles(ImportProviderProfilesRequest) - returns (ImportProviderProfilesResponse); + returns (ImportProviderProfilesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Update an existing custom provider type profile. rpc UpdateProviderProfiles(UpdateProviderProfilesRequest) - returns (UpdateProviderProfilesResponse); + returns (UpdateProviderProfilesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Validate provider type profiles without registering them. rpc LintProviderProfiles(LintProviderProfilesRequest) - returns (LintProviderProfilesResponse); + returns (LintProviderProfilesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // Update an existing provider by name. - rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Fetch refresh status for one provider or provider credential. rpc GetProviderRefreshStatus(GetProviderRefreshStatusRequest) - returns (GetProviderRefreshStatusResponse); + returns (GetProviderRefreshStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:read" + workspace_role: "user" + }; + } // Configure gateway-owned refresh material for one provider credential. rpc ConfigureProviderRefresh(ConfigureProviderRefreshRequest) - returns (ConfigureProviderRefreshResponse); + returns (ConfigureProviderRefreshResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Record a gateway-owned refresh request for one provider credential. rpc RotateProviderCredential(RotateProviderCredentialRequest) - returns (RotateProviderCredentialResponse); + returns (RotateProviderCredentialResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Delete gateway-owned refresh configuration for one provider credential. rpc DeleteProviderRefresh(DeleteProviderRefreshRequest) - returns (DeleteProviderRefreshResponse); + returns (DeleteProviderRefreshResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Delete a provider by name. - rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Delete a custom provider type profile by id. rpc DeleteProviderProfile(DeleteProviderProfileRequest) - returns (DeleteProviderProfileResponse); + returns (DeleteProviderProfileResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "provider:write" + workspace_role: "admin" + }; + } // Get sandbox settings by id (called by sandbox entrypoint and poll loop). rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) - returns (openshell.sandbox.v1.GetSandboxConfigResponse); + returns (openshell.sandbox.v1.GetSandboxConfigResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "dual" + scope: "config:read" + workspace_role: "user" + }; + } // Get gateway-global settings. rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) - returns (openshell.sandbox.v1.GetGatewayConfigResponse); + returns (openshell.sandbox.v1.GetGatewayConfigResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:read" + global_role: "platform_admin" + }; + } // Update settings or policy at sandbox or global scope. rpc UpdateConfig(UpdateConfigRequest) - returns (UpdateConfigResponse); + returns (UpdateConfigResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "dual" + scope: "config:write" + workspace_role: "admin" + }; + } // Get the load status of a specific policy version. rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) - returns (GetSandboxPolicyStatusResponse); + returns (GetSandboxPolicyStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // List policy history for a sandbox. rpc ListSandboxPolicies(ListSandboxPoliciesRequest) - returns (ListSandboxPoliciesResponse); + returns (ListSandboxPoliciesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // Report policy load result (called by sandbox after reload attempt). rpc ReportPolicyStatus(ReportPolicyStatusRequest) - returns (ReportPolicyStatusResponse); + returns (ReportPolicyStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Get provider environment for a sandbox (called by sandbox supervisor at startup). rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) - returns (GetSandboxProviderEnvironmentResponse); + returns (GetSandboxProviderEnvironmentResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Fetch recent sandbox logs (one-shot). - rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // Push sandbox supervisor logs to the server (client-streaming). - rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Persistent supervisor-to-gateway session (bidirectional streaming). // @@ -174,7 +418,11 @@ service OpenShell { // SSH connect, ExecSandbox, and targetable sandbox services. Raw service // bytes flow over RelayStream calls (separate HTTP/2 streams on the same // connection), not over this stream. - rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage); + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Raw byte relay between supervisor and gateway. // @@ -187,7 +435,11 @@ service OpenShell { // // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — // no new TLS handshake, no reverse HTTP CONNECT. - rpc RelayStream(stream RelayFrame) returns (stream RelayFrame); + rpc RelayStream(stream RelayFrame) returns (stream RelayFrame) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Watch a sandbox and stream updates. // @@ -195,7 +447,13 @@ service OpenShell { // - Sandbox status snapshots (phase/status) // - OpenShell server process logs correlated by sandbox_id // - Platform events correlated to the sandbox - rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } // --------------------------------------------------------------------------- // Draft policy recommendation RPCs @@ -203,42 +461,98 @@ service OpenShell { // Submit denial analysis results from sandbox (summaries + proposed chunks). rpc SubmitPolicyAnalysis(SubmitPolicyAnalysisRequest) - returns (SubmitPolicyAnalysisResponse); + returns (SubmitPolicyAnalysisResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Get draft policy recommendations for a sandbox. - rpc GetDraftPolicy(GetDraftPolicyRequest) returns (GetDraftPolicyResponse); + rpc GetDraftPolicy(GetDraftPolicyRequest) returns (GetDraftPolicyResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "dual" + scope: "config:read" + workspace_role: "user" + }; + } // Approve a single draft policy chunk (merges into active policy). rpc ApproveDraftChunk(ApproveDraftChunkRequest) - returns (ApproveDraftChunkResponse); + returns (ApproveDraftChunkResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Reject a single draft policy chunk. rpc RejectDraftChunk(RejectDraftChunkRequest) - returns (RejectDraftChunkResponse); + returns (RejectDraftChunkResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Approve all pending draft chunks (skips security-flagged unless forced). rpc ApproveAllDraftChunks(ApproveAllDraftChunksRequest) - returns (ApproveAllDraftChunksResponse); + returns (ApproveAllDraftChunksResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). - rpc EditDraftChunk(EditDraftChunkRequest) returns (EditDraftChunkResponse); + rpc EditDraftChunk(EditDraftChunkRequest) returns (EditDraftChunkResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Reverse an approval (remove merged rule from active policy). - rpc UndoDraftChunk(UndoDraftChunkRequest) returns (UndoDraftChunkResponse); + rpc UndoDraftChunk(UndoDraftChunkRequest) returns (UndoDraftChunkResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Clear all pending draft chunks for a sandbox. rpc ClearDraftChunks(ClearDraftChunksRequest) - returns (ClearDraftChunksResponse); + returns (ClearDraftChunksResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + workspace_role: "admin" + }; + } // Get decision history for a sandbox's draft policy. - rpc GetDraftHistory(GetDraftHistoryRequest) returns (GetDraftHistoryResponse); + rpc GetDraftHistory(GetDraftHistoryRequest) returns (GetDraftHistoryResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:read" + workspace_role: "user" + }; + } // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected // ServiceAccount token) for a gateway-minted JWT bound to the calling // sandbox's UUID. Used by the Kubernetes driver path; singleplayer // drivers receive the gateway JWT directly from the create-sandbox flow // and never call this RPC. - rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse); + rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // Renew the calling sandbox's gateway JWT. Older tokens remain valid // until their own expiry; deployments should keep token TTLs short to @@ -247,32 +561,78 @@ service OpenShell { // memory only — the on-disk bootstrap file is intentionally not // rewritten. rpc RefreshSandboxToken(RefreshSandboxTokenRequest) - returns (RefreshSandboxTokenResponse); + returns (RefreshSandboxTokenResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "sandbox" + }; + } // --------------------------------------------------------------------------- // Workspace management RPCs // --------------------------------------------------------------------------- // Create a workspace. - rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse); + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:write" + global_role: "platform_admin" + }; + } // Fetch a workspace by name. - rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); + rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:read" + workspace_role: "user" + }; + } // List workspaces. - rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:read" + workspace_role: "user" + }; + } // Delete a workspace by name. - rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); + rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:write" + global_role: "platform_admin" + }; + } // Add a member to a workspace. - rpc AddWorkspaceMember(AddWorkspaceMemberRequest) returns (AddWorkspaceMemberResponse); + rpc AddWorkspaceMember(AddWorkspaceMemberRequest) returns (AddWorkspaceMemberResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:write" + workspace_role: "admin" + }; + } // Remove a member from a workspace. - rpc RemoveWorkspaceMember(RemoveWorkspaceMemberRequest) returns (RemoveWorkspaceMemberResponse); + rpc RemoveWorkspaceMember(RemoveWorkspaceMemberRequest) returns (RemoveWorkspaceMemberResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:write" + workspace_role: "admin" + }; + } // List members of a workspace. - rpc ListWorkspaceMembers(ListWorkspaceMembersRequest) returns (ListWorkspaceMembersResponse); + rpc ListWorkspaceMembers(ListWorkspaceMembersRequest) returns (ListWorkspaceMembersResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "workspace:read" + workspace_role: "user" + }; + } } // IssueSandboxToken request. Empty body; identity is established by the diff --git a/proto/options.proto b/proto/options.proto index ca0764cc3f..7669e2fe1f 100644 --- a/proto/options.proto +++ b/proto/options.proto @@ -7,6 +7,27 @@ package openshell.options.v1; import "google/protobuf/descriptor.proto"; +// Per-method authorization rule. Consumed at runtime by the gateway's +// descriptor-pool-based auth table to enforce auth mode, role, and scope. +message AuthorizationRule { + // Authentication mode: "bearer", "sandbox", "dual", or "unauthenticated". + string auth_mode = 1; + // Minimum workspace-level role required (checked by handler via + // authorize_workspace): "user" or "admin". Mutually exclusive with + // global_role. + string workspace_role = 2; + // Global role required (checked by middleware via OIDC claims): + // "platform_admin". Mutually exclusive with workspace_role. + string global_role = 3; + // Required OIDC scope on the bearer path (e.g. "sandbox:read"). + string scope = 4; +} + +extend google.protobuf.MethodOptions { + // Authorization metadata for a gRPC method. + AuthorizationRule authorization = 50000; +} + // Marks a protobuf field whose value must not cross generic observation or // extension boundaries such as gateway interceptors. extend google.protobuf.FieldOptions { diff --git a/scripts/keycloak-dev.sh b/scripts/keycloak-dev.sh index a330d329b2..e97670dba3 100755 --- a/scripts/keycloak-dev.sh +++ b/scripts/keycloak-dev.sh @@ -109,6 +109,7 @@ print_info() { echo " Test users:" echo " admin@test / admin (role: openshell-admin)" echo " user@test / user (role: openshell-user)" + echo " user-b@test / user-b (role: openshell-user)" echo "" echo " Get a token:" echo " curl -s -X POST ${issuer}/protocol/openid-connect/token \\" diff --git a/scripts/keycloak-realm.json b/scripts/keycloak-realm.json index 7c5234c253..358d04865b 100644 --- a/scripts/keycloak-realm.json +++ b/scripts/keycloak-realm.json @@ -268,6 +268,24 @@ "display.on.consent.screen": "true" } }, + { + "name": "workspace:read", + "description": "Read workspace resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "workspace:write", + "description": "Write workspace resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, { "name": "openshell:all", "description": "Full access to all OpenShell resources", @@ -295,8 +313,22 @@ }, "protocol": "openid-connect", "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "openshell-cli audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "openshell-cli", + "id.token.claim": "true", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ], "defaultClientScopes": ["openid", "profile", "email", "roles", "web-origins", "acr"], - "optionalClientScopes": ["sandbox:read", "sandbox:write", "provider:read", "provider:write", "config:read", "config:write", "inference:read", "inference:write", "openshell:all"] + "optionalClientScopes": ["sandbox:read", "sandbox:write", "provider:read", "provider:write", "config:read", "config:write", "inference:read", "inference:write", "workspace:read", "workspace:write", "openshell:all"] }, { "clientId": "openshell-ci", @@ -310,6 +342,20 @@ "serviceAccountsEnabled": true, "protocol": "openid-connect", "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "openshell-ci audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "openshell-cli", + "id.token.claim": "true", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ], "defaultClientScopes": ["openid", "profile", "email", "roles", "web-origins", "acr", "openshell:all"] } ], @@ -345,6 +391,22 @@ } ], "realmRoles": ["openshell-user"] + }, + { + "username": "user-b@test", + "email": "user-b@test", + "emailVerified": true, + "enabled": true, + "firstName": "Second", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "user-b", + "temporary": false + } + ], + "realmRoles": ["openshell-user"] } ] } diff --git a/tasks/test.toml b/tasks/test.toml index 96dde276ce..cbeda19116 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -92,6 +92,18 @@ run = "e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py' -m ' description = "Run Rust CLI e2e tests against a Podman-backed gateway" run = "e2e/rust/e2e-podman.sh" +["e2e:oidc-pkce"] +description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Podman gateway" +run = [ + "CONTAINER_RUNTIME=podman e2e/with-keycloak.sh env OPENSHELL_E2E_OIDC_GATEWAY=1 e2e/with-podman-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-oidc-pkce --test oidc_pkce", +] + +["e2e:oidc-pkce:docker"] +description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Docker gateway" +run = [ + "CONTAINER_RUNTIME=docker e2e/with-keycloak.sh env OPENSHELL_E2E_OIDC_GATEWAY=1 e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-oidc-pkce --test oidc_pkce", +] + ["e2e:podman:rootless"] description = "Run Rust CLI e2e tests against a rootless Podman-backed gateway" run = "e2e/rust/e2e-podman-rootless.sh"