Skip to content

Commit d4f6c75

Browse files
derekwaynecarrrhuss
authored andcommitted
fix(workspace): address PR review findings and fix clippy errors
Fix bugs found during PR NVIDIA#2243 review: correct migration type strings, resolve platform-scoped profile workspace handling, fix sandbox workspace_tx single-fire, add missing workspace deletion blockers, reset TUI row indices on workspace switch, fix TUI provider update workspace, and update e2e Python tests for workspace and inference route API changes. Add Phase 2 TODO comments for authorization gaps. Fix pre-existing clippy errors (too_many_arguments, cast_sign_loss, iter_on_single_items). Signed-off-by: Derek Carr <decarr@redhat.com>
1 parent 6ed4953 commit d4f6c75

18 files changed

Lines changed: 120 additions & 41 deletions

File tree

‎crates/openshell-cli/src/ssh.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,7 @@ fn resolve_file_download_target(
956956
/// Files are streamed as a tar archive to `ssh ... tar xf - -C <dest>` on
957957
/// the sandbox side. When `dest` is `None`, files are uploaded to the
958958
/// sandbox user's home directory.
959+
#[allow(clippy::too_many_arguments)]
959960
pub async fn sandbox_sync_up_files(
960961
server: &str,
961962
name: &str,

‎crates/openshell-sandbox/src/lib.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2386,7 +2386,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
23862386
} else {
23872387
tokio::time::sleep(interval).await;
23882388
match client.poll_settings(&ctx.sandbox_id).await {
2389-
Ok(result) => result,
2389+
Ok(result) => {
2390+
let _ = ctx.workspace_tx.send(client.workspace());
2391+
result
2392+
}
23902393
Err(e) => {
23912394
debug!(error = %e, "Settings poll: server unreachable, will retry");
23922395
continue;

‎crates/openshell-server/migrations/postgres/006_add_workspace_column.sql‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT '';
33

44
-- Backfill workspace-scoped object types to 'default'.
5+
-- stored_provider_profile is intentionally omitted: profiles use workspace=""
6+
-- for platform scope and are created with an explicit workspace when scoped.
57
UPDATE objects SET workspace = 'default'
68
WHERE workspace = '' AND name IS NOT NULL
7-
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings');
9+
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings');
810

911
-- Replace global name uniqueness with workspace-scoped uniqueness.
1012
DROP INDEX IF EXISTS objects_name_uq;

‎crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT '';
33

44
-- Backfill workspace-scoped object types to 'default'.
5+
-- stored_provider_profile is intentionally omitted: profiles use workspace=""
6+
-- for platform scope and are created with an explicit workspace when scoped.
57
UPDATE objects SET workspace = 'default'
68
WHERE workspace = '' AND name IS NOT NULL
7-
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'policy', 'draft_chunk', 'sandbox_settings');
9+
AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings');
810

911
-- Replace global name uniqueness with workspace-scoped uniqueness.
1012
DROP INDEX IF EXISTS objects_name_uq;

‎crates/openshell-server/src/grpc/mod.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ impl OpenShell for OpenShellService {
249249

250250
type WatchSandboxStream = ReceiverStream<Result<SandboxStreamEvent, Status>>;
251251

252+
// TODO(phase2): data-plane RPCs do not carry a workspace field. Add
253+
// workspace verification to confirm the sandbox belongs to the caller's
254+
// workspace before proxying.
252255
#[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")]
253256
async fn watch_sandbox(
254257
&self,
@@ -265,6 +268,8 @@ impl OpenShell for OpenShellService {
265268
sandbox::handle_get_sandbox(&self.state, request).await
266269
}
267270

271+
// TODO(phase2): all_workspaces flag is currently accessible to any
272+
// authenticated user. Restrict to Platform Admin role in Phase 2.
268273
#[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")]
269274
async fn list_sandboxes(
270275
&self,
@@ -309,6 +314,7 @@ impl OpenShell for OpenShellService {
309314

310315
type ExecSandboxStream = ReceiverStream<Result<ExecSandboxEvent, Status>>;
311316

317+
// TODO(phase2): no workspace field — see watch_sandbox comment.
312318
#[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")]
313319
async fn exec_sandbox(
314320
&self,
@@ -320,6 +326,7 @@ impl OpenShell for OpenShellService {
320326
type ForwardTcpStream =
321327
Pin<Box<dyn tokio_stream::Stream<Item = Result<TcpForwardFrame, Status>> + Send + 'static>>;
322328

329+
// TODO(phase2): no workspace field — see watch_sandbox comment.
323330
#[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")]
324331
async fn forward_tcp(
325332
&self,
@@ -340,6 +347,7 @@ impl OpenShell for OpenShellService {
340347

341348
// --- SSH sessions ---
342349

350+
// TODO(phase2): no workspace field — see watch_sandbox comment.
343351
#[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")]
344352
async fn create_ssh_session(
345353
&self,
@@ -364,6 +372,8 @@ impl OpenShell for OpenShellService {
364372
service::handle_get_service(&self.state, request).await
365373
}
366374

375+
// TODO(phase2): all_workspaces flag is currently accessible to any
376+
// authenticated user. Restrict to Platform Admin role in Phase 2.
367377
#[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")]
368378
async fn list_services(
369379
&self,
@@ -406,6 +416,8 @@ impl OpenShell for OpenShellService {
406416
provider::handle_get_provider(&self.state, request).await
407417
}
408418

419+
// TODO(phase2): all_workspaces flag is currently accessible to any
420+
// authenticated user. Restrict to Platform Admin role in Phase 2.
409421
#[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")]
410422
async fn list_providers(
411423
&self,

‎crates/openshell-server/src/grpc/policy.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2423,6 +2423,8 @@ pub(super) async fn handle_get_sandbox_logs(
24232423
request: Request<GetSandboxLogsRequest>,
24242424
) -> Result<Response<GetSandboxLogsResponse>, Status> {
24252425
let req = request.into_inner();
2426+
// TODO(phase2): workspace is resolved but not used for authorization.
2427+
// Verify the sandbox belongs to this workspace before returning logs.
24262428
let _workspace =
24272429
super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?;
24282430
if req.sandbox_id.is_empty() {

‎crates/openshell-server/src/grpc/provider.rs‎

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,8 @@ pub(super) async fn handle_list_provider_profiles(
13331333
) -> Result<Response<ListProviderProfilesResponse>, Status> {
13341334
let request = request.into_inner();
13351335
let workspace =
1336-
super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?;
1336+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace)
1337+
.await?;
13371338
let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize;
13381339
let offset = request.offset as usize;
13391340
let mut profiles = merged_provider_profiles(state.store.as_ref(), &workspace).await?;
@@ -1353,7 +1354,7 @@ pub(super) async fn handle_get_provider_profile(
13531354
) -> Result<Response<ProviderProfileResponse>, Status> {
13541355
let req = request.into_inner();
13551356
let workspace =
1356-
super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?;
1357+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?;
13571358
let id = req.id;
13581359
let id = normalize_profile_id_request(&id)?;
13591360
let profile = get_provider_type_profile(state.store.as_ref(), &workspace, &id)
@@ -1372,7 +1373,8 @@ pub(super) async fn handle_import_provider_profiles(
13721373
) -> Result<Response<ImportProviderProfilesResponse>, Status> {
13731374
let request = request.into_inner();
13741375
let workspace =
1375-
super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?;
1376+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace)
1377+
.await?;
13761378
let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles);
13771379
add_empty_profile_set_diagnostic(&profiles, &mut diagnostics);
13781380
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await;
@@ -1438,7 +1440,8 @@ pub(super) async fn handle_update_provider_profiles(
14381440
) -> Result<Response<UpdateProviderProfilesResponse>, Status> {
14391441
let request = request.into_inner();
14401442
let workspace =
1441-
super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?;
1443+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace)
1444+
.await?;
14421445
let items = request.profile.into_iter().collect::<Vec<_>>();
14431446
let (profiles, mut diagnostics) = profiles_from_import_items(&items);
14441447
add_empty_profile_set_diagnostic(&profiles, &mut diagnostics);
@@ -1553,7 +1556,8 @@ pub(super) async fn handle_lint_provider_profiles(
15531556
) -> Result<Response<LintProviderProfilesResponse>, Status> {
15541557
let request = request.into_inner();
15551558
let workspace =
1556-
super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?;
1559+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace)
1560+
.await?;
15571561
let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles);
15581562
add_empty_profile_set_diagnostic(&profiles, &mut diagnostics);
15591563
diagnostics
@@ -1573,7 +1577,7 @@ pub(super) async fn handle_delete_provider_profile(
15731577
) -> Result<Response<DeleteProviderProfileResponse>, Status> {
15741578
let req = request.into_inner();
15751579
let workspace =
1576-
super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?;
1580+
super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?;
15771581
let id = req.id;
15781582
let id = normalize_profile_id_request(&id)?;
15791583
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await;
@@ -7086,9 +7090,7 @@ mod tests {
70867090
workspace: "default".to_string(),
70877091
}),
70887092
r#type: "claude".to_string(),
7089-
credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]
7090-
.into_iter()
7091-
.collect(),
7093+
credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]),
70927094
config: HashMap::new(),
70937095
credential_expires_at_ms: HashMap::new(),
70947096
profile_workspace: "other-workspace".to_string(),
@@ -7113,9 +7115,7 @@ mod tests {
71137115
workspace: "default".to_string(),
71147116
}),
71157117
r#type: "claude".to_string(),
7116-
credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]
7117-
.into_iter()
7118-
.collect(),
7118+
credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]),
71197119
config: HashMap::new(),
71207120
credential_expires_at_ms: HashMap::new(),
71217121
profile_workspace: String::new(),
@@ -7139,9 +7139,7 @@ mod tests {
71397139
workspace: "default".to_string(),
71407140
}),
71417141
r#type: "claude".to_string(),
7142-
credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]
7143-
.into_iter()
7144-
.collect(),
7142+
credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]),
71457143
config: HashMap::new(),
71467144
credential_expires_at_ms: HashMap::new(),
71477145
profile_workspace: "default".to_string(),
@@ -7165,9 +7163,7 @@ mod tests {
71657163
workspace: "default".to_string(),
71667164
}),
71677165
r#type: "claude".to_string(),
7168-
credentials: [("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]
7169-
.into_iter()
7170-
.collect(),
7166+
credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]),
71717167
config: HashMap::new(),
71727168
credential_expires_at_ms: HashMap::new(),
71737169
profile_workspace: "default".to_string(),
@@ -7260,9 +7256,7 @@ mod tests {
72607256
workspace: "default".to_string(),
72617257
}),
72627258
r#type: "ws-custom".to_string(),
7263-
credentials: [("TOKEN".to_string(), "val".to_string())]
7264-
.into_iter()
7265-
.collect(),
7259+
credentials: HashMap::from([("TOKEN".to_string(), "val".to_string())]),
72667260
config: HashMap::new(),
72677261
credential_expires_at_ms: HashMap::new(),
72687262
profile_workspace: "default".to_string(),

‎crates/openshell-server/src/grpc/workspace.rs‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ use openshell_core::proto::{
1515
GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest,
1616
ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider,
1717
RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint,
18-
SshSession, StoredProviderProfile, Workspace, WorkspaceMember, WorkspaceRole,
18+
SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace,
19+
WorkspaceMember, WorkspaceRole,
1920
};
2021
use prost::Message;
2122
use tonic::{Request, Response, Status};
2223

2324
use crate::ServerState;
24-
use crate::persistence::{ObjectType, WriteCondition, current_time_ms};
25+
use crate::persistence::{
26+
DRAFT_CHUNK_OBJECT_TYPE, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms,
27+
};
2528

2629
use super::{MAX_PAGE_SIZE, clamp_limit};
2730

@@ -48,10 +51,30 @@ fn validate_workspace_name(name: &str) -> Result<(), Status> {
4851
super::validation::validate_dns1123_label(name, "workspace name")
4952
}
5053

54+
/// Resolve a workspace for provider profile operations.
55+
///
56+
/// Provider profiles support a platform scope where `""` is a distinct,
57+
/// meaningful value (not an alias for `"default"`). This function preserves
58+
/// `""` as-is for platform-scoped operations. Non-empty workspace values are
59+
/// validated for existence via [`resolve_workspace`].
60+
pub async fn resolve_profile_workspace(
61+
store: &crate::persistence::Store,
62+
workspace: &str,
63+
) -> Result<String, Status> {
64+
if workspace.is_empty() {
65+
return Ok(String::new());
66+
}
67+
resolve_workspace(store, workspace).await
68+
}
69+
5170
/// Resolve and validate a workspace name from a request field.
5271
///
5372
/// Empty strings are normalized to `"default"`. The workspace must exist in the
5473
/// store; returns `NOT_FOUND` if it doesn't.
74+
///
75+
/// TODO(phase2): this only validates existence. Workspace membership enforcement
76+
/// (checking the caller is a member of the resolved workspace) is deferred to
77+
/// Phase 2.
5578
pub async fn resolve_workspace(
5679
store: &crate::persistence::Store,
5780
workspace: &str,
@@ -190,7 +213,15 @@ pub(super) async fn handle_delete_workspace(
190213
));
191214
}
192215

216+
// TOCTOU: the blocker scan and the subsequent delete are not transactional,
217+
// so a resource could be created between the check and the delete. The
218+
// persistence layer does not currently offer a transaction primitive that
219+
// spans both reads and deletes. Acceptable for now — workspace deletion is
220+
// an admin-only, low-frequency operation.
193221
let mut blocking = Vec::new();
222+
// Policy revisions and draft chunks are normally deleted when their parent
223+
// sandbox is removed, and refresh state is transient; these checks are a
224+
// safety net against regressions.
194225
for (object_type, label) in [
195226
(Sandbox::object_type(), "sandbox"),
196227
(Provider::object_type(), "provider"),
@@ -199,6 +230,9 @@ pub(super) async fn handle_delete_workspace(
199230
(InferenceRoute::object_type(), "inference route"),
200231
(SshSession::object_type(), "ssh session"),
201232
(super::policy::SANDBOX_SETTINGS_OBJECT_TYPE, "sandbox settings"),
233+
(POLICY_OBJECT_TYPE, "sandbox policy"),
234+
(DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunk"),
235+
(StoredProviderCredentialRefreshState::object_type(), "credential refresh state"),
202236
] {
203237
let records = state
204238
.store

‎crates/openshell-server/src/persistence/mod.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ pub fn generate_name() -> String {
140140
/// Decode a single [`ObjectRecord`] into a protobuf message, hydrating
141141
/// `resource_version` from the authoritative DB row.
142142
///
143+
/// Only `resource_version` is hydrated here; `workspace` is NOT backfilled from
144+
/// the DB column because the workspace field is authoritative in the protobuf
145+
/// payload at creation time. This is a breaking upgrade — pre-workspace records
146+
/// will carry an empty workspace until they are re-created.
147+
///
143148
/// Extracted to avoid repeating the identical decode-and-hydrate block across
144149
/// `get_message`, `get_message_by_name`, `list_messages`, and
145150
/// `list_messages_with_selector`.

‎crates/openshell-server/src/persistence/postgres.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3
361361
.fetch_one(&self.pool)
362362
.await
363363
.map_err(|e| map_db_error(&e))?;
364-
Ok(row.0 as u64)
364+
Ok(u64::try_from(row.0).unwrap_or(0))
365365
}
366366

367367
pub async fn delete_all_in_workspace(

0 commit comments

Comments
 (0)