From 5aec014ab32d6679552f93f09bf04c447384df32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20D=C3=BCrr?= <102963075+cd-work@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:31:15 +0000 Subject: [PATCH] Add org support to `phylum group` (#1488) This patch adds org support to the `list`/`create`/`delete` subcommands of `phylum group`. The `member` subcommand is unchanged, since user management is performed on the org level instead. The `create` and `delete` subcommands will always use the currently linked organization, with no way to temporarily override it. While this could be annoying to some, it avoids adding extra flags that would get deprecated in the future. Most users likely always or never work within organizations and for people working in-between it's easiest to avoid linking orgs and just using `--org` instead as required. See #1482. --- CHANGELOG.md | 1 + cli/src/api/endpoints.rs | 23 +++++++ cli/src/api/mod.rs | 29 +++++++-- cli/src/bin/phylum.rs | 2 +- cli/src/commands/group.rs | 96 ++++++++++++++++++++++++---- cli/src/format.rs | 41 +++++++++--- cli/src/types.rs | 12 ++++ doc_templates/phylum_group_create.md | 8 +++ doc_templates/phylum_group_delete.md | 8 +++ doc_templates/phylum_group_list.md | 3 + docs/commands/phylum_group_create.md | 8 +++ docs/commands/phylum_group_delete.md | 8 +++ docs/commands/phylum_group_list.md | 3 + 13 files changed, 216 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc88dffc..e0fba4525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Organization management under the `phylum org` subcommand - `phylum project update --default-label` option to set a project's default label - `phylum project list --no-group` flag to only show personal projects +- Organization support for `phylum group` subcommands ## 6.6.6 - 2024-07-12 diff --git a/cli/src/api/endpoints.rs b/cli/src/api/endpoints.rs index fa22e9a82..95d0683f2 100644 --- a/cli/src/api/endpoints.rs +++ b/cli/src/api/endpoints.rs @@ -169,6 +169,29 @@ pub fn org_member_remove(api_uri: &str, org: &str, email: &str) -> Result/groups +pub fn org_groups(api_uri: &str, org_name: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend(["organizations", org_name, "groups"]); + Ok(url) +} + +/// DELETE /organizations//groups/ +pub fn org_groups_delete( + api_uri: &str, + org_name: &str, + group_name: &str, +) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "organizations", + org_name, + "groups", + group_name, + ]); + Ok(url) +} + /// GET /.well-known/openid-configuration pub fn oidc_discovery(api_uri: &str) -> Result { Ok(get_api_path(api_uri)?.join(".well-known/openid-configuration")?) diff --git a/cli/src/api/mod.rs b/cli/src/api/mod.rs index 7584243ad..3e89626b9 100644 --- a/cli/src/api/mod.rs +++ b/cli/src/api/mod.rs @@ -27,10 +27,10 @@ use crate::auth::{ use crate::config::{AuthInfo, Config}; use crate::types::{ AddOrgUserRequest, AnalysisPackageDescriptor, CreateProjectRequest, GetProjectResponse, - HistoryJob, ListUserGroupsResponse, OrgMembersResponse, OrgsResponse, PackageSpecifier, - PackageSubmitResponse, Paginated, PingResponse, PolicyEvaluationRequest, - PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, RevokeTokenRequest, - SubmitPackageRequest, UpdateProjectRequest, UserToken, + HistoryJob, ListUserGroupsResponse, OrgGroup, OrgGroupsResponse, OrgMembersResponse, + OrgsResponse, PackageSpecifier, PackageSubmitResponse, Paginated, PingResponse, + PolicyEvaluationRequest, PolicyEvaluationResponse, PolicyEvaluationResponseRaw, + ProjectListEntry, RevokeTokenRequest, SubmitPackageRequest, UpdateProjectRequest, UserToken, }; pub mod endpoints; @@ -495,6 +495,27 @@ impl PhylumApi { Ok(()) } + /// Get all groups for on organization. + pub async fn org_groups(&self, org_name: &str) -> Result { + let url = endpoints::org_groups(&self.config.connection.uri, org_name)?; + self.get(url).await + } + + /// Create a new organization group. + pub async fn org_create_group(&self, org_name: &str, group_name: &str) -> Result<()> { + let url = endpoints::org_groups(&self.config.connection.uri, org_name)?; + let body = OrgGroup { name: group_name.into() }; + self.send_request_raw(Method::POST, url, Some(body)).await?; + Ok(()) + } + + /// Delete an organization group. + pub async fn org_delete_group(&self, org_name: &str, group_name: &str) -> Result<()> { + let url = endpoints::org_groups_delete(&self.config.connection.uri, org_name, group_name)?; + self.send_request_raw(Method::DELETE, url, None::<()>).await?; + Ok(()) + } + /// List a user's locksmith tokens. pub async fn list_tokens(&self) -> Result> { let url = endpoints::list_tokens(&self.config.connection.uri)?; diff --git a/cli/src/bin/phylum.rs b/cli/src/bin/phylum.rs index fdad7e5fb..4926473bd 100644 --- a/cli/src/bin/phylum.rs +++ b/cli/src/bin/phylum.rs @@ -139,7 +139,7 @@ async fn handle_commands() -> CommandResult { }, "package" => packages::handle_get_package(&Spinner::wrap(api).await?, sub_matches).await, "history" => jobs::handle_history(&Spinner::wrap(api).await?, sub_matches).await, - "group" => group::handle_group(&Spinner::wrap(api).await?, sub_matches).await, + "group" => group::handle_group(&Spinner::wrap(api).await?, sub_matches, config).await, "analyze" | "batch" => jobs::handle_submission(&Spinner::wrap(api).await?, &matches).await, "init" => init::handle_init(&Spinner::wrap(api).await?, sub_matches).await, "status" => status::handle_status(sub_matches).await, diff --git a/cli/src/commands/group.rs b/cli/src/commands/group.rs index f47030027..14dde0fe2 100644 --- a/cli/src/commands/group.rs +++ b/cli/src/commands/group.rs @@ -1,19 +1,23 @@ //! Subcommand `phylum group`. +use std::cmp::Ordering; + use clap::ArgMatches; use reqwest::StatusCode; +use serde::Serialize; use crate::api::{PhylumApi, PhylumApiError, ResponseError}; use crate::commands::{CommandResult, ExitCode}; +use crate::config::Config; use crate::format::Format; use crate::{print_user_failure, print_user_success}; /// Handle `phylum group` subcommand. -pub async fn handle_group(api: &PhylumApi, matches: &ArgMatches) -> CommandResult { +pub async fn handle_group(api: &PhylumApi, matches: &ArgMatches, config: Config) -> CommandResult { match matches.subcommand() { Some(("list", matches)) => handle_group_list(api, matches).await, - Some(("create", matches)) => handle_group_create(api, matches).await, - Some(("delete", matches)) => handle_group_delete(api, matches).await, + Some(("create", matches)) => handle_group_create(api, matches, config).await, + Some(("delete", matches)) => handle_group_delete(api, matches, config).await, Some(("member", matches)) => { let group = matches.get_one::("group").unwrap(); @@ -29,25 +33,59 @@ pub async fn handle_group(api: &PhylumApi, matches: &ArgMatches) -> CommandResul } /// Handle `phylum group create` subcommand. -pub async fn handle_group_create(api: &PhylumApi, matches: &ArgMatches) -> CommandResult { +pub async fn handle_group_create( + api: &PhylumApi, + matches: &ArgMatches, + config: Config, +) -> CommandResult { let group_name = matches.get_one::("group_name").unwrap(); - match api.create_group(group_name).await { - Ok(response) => { - print_user_success!("Successfully created group {}", response.group_name); + + let org = config.org(); + let response = if let Some(org) = org { + api.org_create_group(org, group_name).await + } else { + api.create_group(group_name).await.map(|_| ()) + }; + + match response { + Ok(_) => { + print_user_success!("Successfully created group {}", group_name); Ok(ExitCode::Ok) }, Err(PhylumApiError::Response(ResponseError { code: StatusCode::CONFLICT, .. })) => { print_user_failure!("Group '{}' already exists", group_name); Ok(ExitCode::AlreadyExists) }, + Err(PhylumApiError::Response(ResponseError { code: StatusCode::FORBIDDEN, .. })) + if org.is_some() => + { + print_user_failure!("Authorization failed, only organization admins can create groups"); + Ok(ExitCode::NotAuthenticated) + }, Err(err) => Err(err.into()), } } /// Handle `phylum group delete` subcommand. -pub async fn handle_group_delete(api: &PhylumApi, matches: &ArgMatches) -> CommandResult { +pub async fn handle_group_delete( + api: &PhylumApi, + matches: &ArgMatches, + config: Config, +) -> CommandResult { let group_name = matches.get_one::("group_name").unwrap(); - api.delete_group(group_name).await?; + + if let Some(org) = config.org() { + let response = api.org_delete_group(org, group_name).await; + if let Err(PhylumApiError::Response(ResponseError { + code: StatusCode::FORBIDDEN, .. + })) = response + { + print_user_failure!("Authorization failed, only organization admins can delete groups"); + return Ok(ExitCode::NotAuthenticated); + } + } else { + api.delete_group(group_name).await?; + }; print_user_success!("Successfully deleted group {}", group_name); @@ -56,10 +94,39 @@ pub async fn handle_group_delete(api: &PhylumApi, matches: &ArgMatches) -> Comma /// Handle `phylum group list` subcommand. pub async fn handle_group_list(api: &PhylumApi, matches: &ArgMatches) -> CommandResult { - let response = api.get_groups_list().await?; + // Get org groups. + let mut groups = Vec::new(); + match matches.get_one::("org") { + // If org is explicitly specified, only show its groups. + Some(org_name) => { + for group in api.org_groups(org_name).await?.groups { + groups.push(ListGroupsEntry { org: Some(org_name.clone()), name: group.name }); + } + }, + // If org is not specified as CLI arg, print all org and and legacy groups. + None => { + let legacy_groups = api.get_groups_list().await?.groups; + groups = legacy_groups + .into_iter() + .map(|group| ListGroupsEntry { name: group.group_name, org: None }) + .collect(); + + for org in api.orgs().await?.organizations { + for group in api.org_groups(&org.name).await?.groups { + groups.push(ListGroupsEntry { org: Some(org.name.clone()), name: group.name }); + } + } + }, + } + + // Sort response for more consistent output. + groups.sort_unstable_by(|a, b| match a.org.cmp(&b.org) { + Ordering::Equal => a.name.cmp(&b.name), + ordering => ordering, + }); let pretty = !matches.get_flag("json"); - response.write_stdout(pretty); + groups.write_stdout(pretty); Ok(ExitCode::Ok) } @@ -109,3 +176,10 @@ pub async fn handle_member_list( Ok(ExitCode::Ok) } + +/// Output entry in the `phylum group list` subcommand. +#[derive(Serialize)] +pub struct ListGroupsEntry { + pub org: Option, + pub name: String, +} diff --git a/cli/src/format.rs b/cli/src/format.rs index eb05e0d8e..f261e9ba1 100644 --- a/cli/src/format.rs +++ b/cli/src/format.rs @@ -15,12 +15,12 @@ use unicode_width::UnicodeWidthStr; #[cfg(feature = "vulnreach")] use vulnreach_types::Vulnerability; +use crate::commands::group::ListGroupsEntry; use crate::commands::status::PhylumStatus; use crate::print::{self, table_format}; use crate::types::{ - GetProjectResponse, HistoryJob, Issue, ListUserGroupsResponse, OrgMember, OrgMembersResponse, - OrgsResponse, Package, PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, - RiskLevel, UserGroup, UserToken, + GetProjectResponse, HistoryJob, Issue, OrgMember, OrgMembersResponse, OrgsResponse, Package, + PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, RiskLevel, UserToken, }; // Maximum length of email column. @@ -203,16 +203,37 @@ impl Format for GetProjectResponse { } } -impl Format for ListUserGroupsResponse { +impl Format for Vec { fn pretty(&self, writer: &mut W) { - // Maximum length of group name column. + // Maximum length of org and group name columns. const MAX_NAME_WIDTH: usize = 25; - let table = format_table:: String, _>(&self.groups, &[ - ("Group Name", |group| print::truncate(&group.group_name, MAX_NAME_WIDTH).into_owned()), - ("Creation Time", |group| format_datetime(group.created_at)), - ]); - let _ = writeln!(writer, "{table}"); + // Skip table formatting with no groups. + if self.is_empty() { + let _ = writeln!( + writer, + "You don't have any groups yet; create one with `phylum group create `." + ); + return; + } + + // Use condensed format if only legacy groups are present. + if self.iter().all(|group| group.org.is_none()) { + let table = format_table:: String, _>(self, &[( + "Group Name", + |group| print::truncate(&group.name, MAX_NAME_WIDTH).into_owned(), + )]); + let _ = writeln!(writer, "{table}"); + } else { + let table = format_table:: String, _>(self, &[ + ("Organization Name", |group| match &group.org { + Some(org) => print::truncate(org, MAX_NAME_WIDTH).into_owned(), + None => String::new(), + }), + ("Group Name", |group| print::truncate(&group.name, MAX_NAME_WIDTH).into_owned()), + ]); + let _ = writeln!(writer, "{table}"); + } } } diff --git a/cli/src/types.rs b/cli/src/types.rs index e06760a15..3aa4a9a21 100644 --- a/cli/src/types.rs +++ b/cli/src/types.rs @@ -520,3 +520,15 @@ pub struct GetProjectResponse { pub default_label: Option, pub repository_url: Option, } + +/// Response body for Phylum's GET /organizations//groups endpoint. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)] +pub struct OrgGroupsResponse { + pub groups: Vec, +} + +/// Group returned by Phylum's GET /organizations//groups endpoint. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)] +pub struct OrgGroup { + pub name: String, +} diff --git a/doc_templates/phylum_group_create.md b/doc_templates/phylum_group_create.md index d72d67189..890738a57 100644 --- a/doc_templates/phylum_group_create.md +++ b/doc_templates/phylum_group_create.md @@ -7,4 +7,12 @@ ```sh # Create a new group named `sample` $ phylum group create sample + +# Create a group `sample` under the `test` organization +$ phylum group create --org test sample + +# Make `test` the default organization for all operations, +# then create a new group `sample` under it. +$ phylum org link test +$ phylum group create sample ``` diff --git a/doc_templates/phylum_group_delete.md b/doc_templates/phylum_group_delete.md index 98fc07596..a3cf0c2a7 100644 --- a/doc_templates/phylum_group_delete.md +++ b/doc_templates/phylum_group_delete.md @@ -7,4 +7,12 @@ ```sh # Delete an existing group named `sample` $ phylum group delete sample + +# Delete the group `sample` from the `test` organization +$ phylum group delete --org test sample + +# Make `test` the default organization for all operations, +# then delete the group `sample` from it. +$ phylum org link test +$ phylum group delete sample ``` diff --git a/doc_templates/phylum_group_list.md b/doc_templates/phylum_group_list.md index 65fdbd995..641373902 100644 --- a/doc_templates/phylum_group_list.md +++ b/doc_templates/phylum_group_list.md @@ -10,4 +10,7 @@ $ phylum group list # List all groups the user is a member of with json output $ phylum group list --json + +# List all groups for the `test` organization +$ phylum group list --org test ``` diff --git a/docs/commands/phylum_group_create.md b/docs/commands/phylum_group_create.md index 26fd1516d..b62a50a0b 100644 --- a/docs/commands/phylum_group_create.md +++ b/docs/commands/phylum_group_create.md @@ -30,4 +30,12 @@ Usage: phylum group create [OPTIONS] ```sh # Create a new group named `sample` $ phylum group create sample + +# Create a group `sample` under the `test` organization +$ phylum group create --org test sample + +# Make `test` the default organization for all operations, +# then create a new group `sample` under it. +$ phylum org link test +$ phylum group create sample ``` diff --git a/docs/commands/phylum_group_delete.md b/docs/commands/phylum_group_delete.md index c419212bf..0daf37edc 100644 --- a/docs/commands/phylum_group_delete.md +++ b/docs/commands/phylum_group_delete.md @@ -30,4 +30,12 @@ Usage: phylum group delete [OPTIONS] ```sh # Delete an existing group named `sample` $ phylum group delete sample + +# Delete the group `sample` from the `test` organization +$ phylum group delete --org test sample + +# Make `test` the default organization for all operations, +# then delete the group `sample` from it. +$ phylum org link test +$ phylum group delete sample ``` diff --git a/docs/commands/phylum_group_list.md b/docs/commands/phylum_group_list.md index a4574d7e3..a6a68cafb 100644 --- a/docs/commands/phylum_group_list.md +++ b/docs/commands/phylum_group_list.md @@ -31,4 +31,7 @@ $ phylum group list # List all groups the user is a member of with json output $ phylum group list --json + +# List all groups for the `test` organization +$ phylum group list --org test ```