diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 7fe675e..38eb059 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -426,6 +426,38 @@ 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 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) + .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 +503,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 +863,46 @@ 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 = 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"); + } + 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 sharing 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 +1433,58 @@ 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 url = self.workspace_grant_url(app_id, 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, + "expected_revision": expected_revision, + }); + 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") + }) + } + + 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, @@ -2300,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"; @@ -4610,6 +4786,78 @@ 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 || { + for method in ["GET", "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"); + 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(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"), + ), + ) + .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 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, + "my-app", + "team-workspace", + "life-123", + "grant", + 4, + ) + .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"); + } + #[test] fn access_set_rejects_unknown_visibility_before_auth_or_network() { let error = command()