From d24948c3382dbd12d20c7cc507c822265895e9fe Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 25 Sep 2026 11:34:00 -0400 Subject: [PATCH 1/4] Add CLI workspace sharing commands --- src/bl/apps.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 7fe675e..466757a 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -426,6 +426,37 @@ pub fn command() -> Command { ), )), ) + .subcommand( + Command::new("share") + .about("Share a restricted app with a BuilderLab workspace") + .long_about( + "Grant or revoke viewing access for current members of a non-personal BuilderLab workspace. \ + The app owner must belong to the target workspace. Use `bl apps access get` to see current grants.", + ) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(share_action_command("grant")) + .subcommand(share_action_command("revoke")), + ) +} + +fn share_action_command(action: &'static str) -> Command { + control_plane_args( + Command::new(action) + .about(if action == "grant" { + "Let current members of a workspace view an app" + } else { + "Remove a workspace's viewing access to an app" + }) + .arg(Arg::new("app-id").value_name("APP_ID").required(true)) + .arg( + Arg::new("workspace-id") + .value_name("WORKSPACE_ID") + .required(true) + .help("BuilderLab workspace identifier"), + ), + ) } fn control_plane_args(command: Command) -> Command { @@ -471,6 +502,7 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("ready", ready_matches)) => run_ready(config, ready_matches), Some(("debug", debug_matches)) => run_debug(config, debug_matches), Some(("access", access_matches)) => run_access(config, access_matches), + Some(("share", share_matches)) => run_share(config, share_matches), _ => anyhow::bail!("expected an apps subcommand"), } } @@ -830,6 +862,41 @@ fn run_access(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { } } +fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let (action, action_matches) = matches + .subcommand() + .context("expected share grant or revoke")?; + let app_id = action_matches + .get_one::("app-id") + .context("expected app id")?; + let workspace_id = action_matches + .get_one::("workspace-id") + .context("expected workspace id")?; + let (client, credential) = control_plane_context(config, action_matches)?; + let current = client.get_access(&credential, app_id, None)?; + if current.get("visibility").and_then(Value::as_str) != Some("restricted") { + anyhow::bail!("workspace sharing requires restricted app visibility"); + } + let lifecycle_id = current + .get("lifecycle_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .context("app has no active sharing lifecycle")?; + let expected_revision = current + .get("workspace_grant_revision") + .and_then(Value::as_u64) + .context("app access response has no workspace grant revision")?; + let response = client.update_workspace_grant( + &credential, + app_id, + workspace_id, + lifecycle_id, + action, + expected_revision, + )?; + print_json(&response) +} + fn run_access_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let app_id = matches .get_one::("app-id") @@ -1360,6 +1427,35 @@ impl ControlPlaneClient { }) } + fn update_workspace_grant( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + workspace_id: &str, + lifecycle_id: &str, + action: &str, + expected_revision: u64, + ) -> Result { + let mut url = self.app_url(app_id, &[])?; + url.path_segments_mut() + .map_err(|_| { + anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") + })? + .extend(["sharing", "workspaces", workspace_id]); + let path = request_path(&url); + let body = json!({ + "lifecycle_id": lifecycle_id, + "action": action, + "expected_revision": expected_revision, + }); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(&body) + .build() + .context("build Apps Platform workspace sharing request") + }) + } + fn get_app_resource( &self, credential: &ComposeSessionCredential, @@ -4610,6 +4706,51 @@ mod tests { server_thread.join().expect("join control-plane server"); } + #[test] + fn workspace_grant_uses_target_path_and_lifecycle_revision() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let mut request = server.recv().expect("receive sharing request"); + assert_eq!(request.method().as_str(), "POST"); + assert_eq!( + request.url(), + "/v1/agent/apps/my-app/sharing/workspaces/team-workspace" + ); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read body"); + assert_eq!( + serde_json::from_str::(&body).expect("parse body"), + json!({"lifecycle_id":"life-123","action":"grant","expected_revision":4}) + ); + request + .respond( + Response::from_string(r#"{"ok":true}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to sharing request"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("sharing_session_credential_123456789012345"); + let response = client + .update_workspace_grant( + &credential, + "my-app", + "team-workspace", + "life-123", + "grant", + 4, + ) + .expect("update workspace grant"); + assert_eq!(response["ok"], true); + server_thread.join().expect("join control-plane server"); + } + #[test] fn access_set_rejects_unknown_visibility_before_auth_or_network() { let error = command() From cf051cf9323a09a6dadf99fa58c21764ffc92f10 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 25 Sep 2026 12:05:17 -0400 Subject: [PATCH 2/4] Use separate grant and revoke methods for app sharing --- src/bl/apps.rs | 74 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 466757a..2494246 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -1443,13 +1443,22 @@ impl ControlPlaneClient { })? .extend(["sharing", "workspaces", workspace_id]); let path = request_path(&url); + let method = match action { + "grant" => "POST", + "revoke" => "DELETE", + _ => anyhow::bail!("unsupported workspace sharing action"), + }; let body = json!({ "lifecycle_id": lifecycle_id, - "action": action, "expected_revision": expected_revision, }); - self.authorized_json_request(credential, "POST", &path, |authorization| { - self.standard_request(self.client.post(url.clone()), authorization) + self.authorized_json_request(credential, method, &path, |authorization| { + let request = if method == "POST" { + self.client.post(url.clone()) + } else { + self.client.delete(url.clone()) + }; + self.standard_request(request, authorization) .json(&body) .build() .context("build Apps Platform workspace sharing request") @@ -4711,29 +4720,31 @@ mod tests { let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); let server_thread = thread::spawn(move || { - let mut request = server.recv().expect("receive sharing request"); - assert_eq!(request.method().as_str(), "POST"); - assert_eq!( - request.url(), - "/v1/agent/apps/my-app/sharing/workspaces/team-workspace" - ); - let mut body = String::new(); - request - .as_reader() - .read_to_string(&mut body) - .expect("read body"); - assert_eq!( - serde_json::from_str::(&body).expect("parse body"), - json!({"lifecycle_id":"life-123","action":"grant","expected_revision":4}) - ); - request - .respond( - Response::from_string(r#"{"ok":true}"#).with_header( - Header::from_bytes("Content-Type", "application/json") - .expect("build content type"), - ), - ) - .expect("respond to sharing request"); + for method in ["POST", "DELETE"] { + let mut request = server.recv().expect("receive sharing request"); + assert_eq!(request.method().as_str(), method); + assert_eq!( + request.url(), + "/v1/agent/apps/my-app/sharing/workspaces/team-workspace" + ); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read body"); + assert_eq!( + serde_json::from_str::(&body).expect("parse body"), + json!({"lifecycle_id":"life-123","expected_revision":if method == "POST" { 4 } else { 5 }}) + ); + request + .respond( + Response::from_string(r#"{"ok":true}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to sharing request"); + } }); let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let credential = test_credential("sharing_session_credential_123456789012345"); @@ -4748,6 +4759,17 @@ mod tests { ) .expect("update workspace grant"); assert_eq!(response["ok"], true); + let response = client + .update_workspace_grant( + &credential, + "my-app", + "team-workspace", + "life-123", + "revoke", + 5, + ) + .expect("revoke workspace grant"); + assert_eq!(response["ok"], true); server_thread.join().expect("join control-plane server"); } From d8acf27b1fe9d0965a7ebab775f0ab6316fa8a34 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 25 Sep 2026 13:27:10 -0400 Subject: [PATCH 3/4] Allow revoking workspace grants after visibility changes --- src/bl/apps.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 2494246..c74702b 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -874,7 +874,8 @@ fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { .context("expected workspace id")?; let (client, credential) = control_plane_context(config, action_matches)?; let current = client.get_access(&credential, app_id, None)?; - if current.get("visibility").and_then(Value::as_str) != Some("restricted") { + if action == "grant" && current.get("visibility").and_then(Value::as_str) != Some("restricted") + { anyhow::bail!("workspace sharing requires restricted app visibility"); } let lifecycle_id = current From 85f5f8f866a4882187746c8b8ccbab1efe611f25 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 25 Sep 2026 14:35:26 -0400 Subject: [PATCH 4/4] Read owner-scoped grant revision before workspace revoke --- src/bl/apps.rs | 114 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 15 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index c74702b..38eb059 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -431,7 +431,8 @@ pub fn command() -> Command { .about("Share a restricted app with a BuilderLab workspace") .long_about( "Grant or revoke viewing access for current members of a non-personal BuilderLab workspace. \ - The app owner must belong to the target workspace. Use `bl apps access get` to see current grants.", + The app owner must belong to the target workspace to grant access, but can revoke after leaving it. \ + Use `bl apps access get` to see current grants.", ) .subcommand_required(true) .arg_required_else_help(true) @@ -873,7 +874,11 @@ fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { .get_one::("workspace-id") .context("expected workspace id")?; let (client, credential) = control_plane_context(config, action_matches)?; - let current = client.get_access(&credential, app_id, None)?; + let current = if action == "grant" { + client.get_access(&credential, app_id, None)? + } else { + client.get_workspace_grant_revision(&credential, app_id, workspace_id)? + }; if action == "grant" && current.get("visibility").and_then(Value::as_str) != Some("restricted") { anyhow::bail!("workspace sharing requires restricted app visibility"); @@ -886,7 +891,7 @@ fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let expected_revision = current .get("workspace_grant_revision") .and_then(Value::as_u64) - .context("app access response has no workspace grant revision")?; + .context("app sharing response has no workspace grant revision")?; let response = client.update_workspace_grant( &credential, app_id, @@ -1437,12 +1442,7 @@ impl ControlPlaneClient { action: &str, expected_revision: u64, ) -> Result { - let mut url = self.app_url(app_id, &[])?; - url.path_segments_mut() - .map_err(|_| { - anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") - })? - .extend(["sharing", "workspaces", workspace_id]); + let url = self.workspace_grant_url(app_id, workspace_id)?; let path = request_path(&url); let method = match action { "grant" => "POST", @@ -1466,6 +1466,25 @@ impl ControlPlaneClient { }) } + fn get_workspace_grant_revision( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + workspace_id: &str, + ) -> Result { + self.get_url(credential, self.workspace_grant_url(app_id, workspace_id)?) + } + + fn workspace_grant_url(&self, app_id: &str, workspace_id: &str) -> Result { + let mut url = self.app_url(app_id, &[])?; + url.path_segments_mut() + .map_err(|_| { + anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") + })? + .extend(["sharing", "workspaces", workspace_id]); + Ok(url) + } + fn get_app_resource( &self, credential: &ComposeSessionCredential, @@ -2406,6 +2425,57 @@ mod tests { ); } + #[test] + fn bl_apps_share_revoke_reads_only_the_target_grant_before_delete() { + let credential = "apps-e2e-only.revoke.session+credential"; + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(json!({ + "ok": true, + "app_id": "my-app", + "lifecycle_id": "life-123", + "workspace_grant_revision": 7, + })), + ProcessResponse::json(json!({"ok": true, "workspace_grant_revision": 8})), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "share", + "revoke", + "my-app", + "former-team", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + ], + credential, + ); + let output = command + .output() + .expect("run workspace revoke process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let auth_requests = auth_server.finish(); + assert_eq!(auth_requests.len(), 1); + assert_process_auth(&auth_requests[0], credential); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 2); + let path = "/v1/agent/apps/my-app/sharing/workspaces/former-team"; + assert_process_control_plane(&requests[0], "GET", path, credential); + assert_process_control_plane(&requests[1], "DELETE", path, credential); + assert_eq!( + requests[1].body, + json!({"lifecycle_id":"life-123","expected_revision":7}) + ); + } + #[test] fn bl_apps_access_process_explicitly_clears_restricted_viewers() { let credential = "apps-e2e-only.access.clear.session+credential"; @@ -4721,7 +4791,7 @@ mod tests { let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); let server_thread = thread::spawn(move || { - for method in ["POST", "DELETE"] { + for method in ["GET", "POST", "DELETE"] { let mut request = server.recv().expect("receive sharing request"); assert_eq!(request.method().as_str(), method); assert_eq!( @@ -4733,13 +4803,22 @@ mod tests { .as_reader() .read_to_string(&mut body) .expect("read body"); - assert_eq!( - serde_json::from_str::(&body).expect("parse body"), - json!({"lifecycle_id":"life-123","expected_revision":if method == "POST" { 4 } else { 5 }}) - ); + if method == "GET" { + assert!(body.is_empty()); + } else { + assert_eq!( + serde_json::from_str::(&body).expect("parse body"), + json!({"lifecycle_id":"life-123","expected_revision":if method == "POST" { 4 } else { 5 }}) + ); + } request .respond( - Response::from_string(r#"{"ok":true}"#).with_header( + Response::from_string(if method == "GET" { + r#"{"ok":true,"lifecycle_id":"life-123","workspace_grant_revision":5}"# + } else { + r#"{"ok":true}"# + }) + .with_header( Header::from_bytes("Content-Type", "application/json") .expect("build content type"), ), @@ -4749,6 +4828,11 @@ mod tests { }); let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let credential = test_credential("sharing_session_credential_123456789012345"); + let current = client + .get_workspace_grant_revision(&credential, "my-app", "team-workspace") + .expect("read sharing revision without deployment workspace access"); + assert_eq!(current["lifecycle_id"], "life-123"); + assert_eq!(current["workspace_grant_revision"], 5); let response = client .update_workspace_grant( &credential,