Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions src/bl/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
}
}
Expand Down Expand Up @@ -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::<String>("app-id")
.context("expected app id")?;
let workspace_id = action_matches
.get_one::<String>("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::<String>("app-id")
Expand Down Expand Up @@ -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<Value> {
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<Value> {
self.get_url(credential, self.workspace_grant_url(app_id, workspace_id)?)
}

fn workspace_grant_url(&self, app_id: &str, workspace_id: &str) -> Result<url::Url> {
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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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::<Value>(&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()
Expand Down
Loading