Skip to content

Commit

Permalink
Add org support to phylum group (#1488)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cd-work authored Sep 5, 2024
1 parent be97f43 commit 5aec014
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions cli/src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ pub fn org_member_remove(api_uri: &str, org: &str, email: &str) -> Result<Url, B
Ok(url)
}

/// GET/POST /organizations/<orgName>/groups
pub fn org_groups(api_uri: &str, org_name: &str) -> Result<Url, BaseUriError> {
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/<orgName>/groups/<groupName>
pub fn org_groups_delete(
api_uri: &str,
org_name: &str,
group_name: &str,
) -> Result<Url, BaseUriError> {
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<Url, BaseUriError> {
Ok(get_api_path(api_uri)?.join(".well-known/openid-configuration")?)
Expand Down
29 changes: 25 additions & 4 deletions cli/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -495,6 +495,27 @@ impl PhylumApi {
Ok(())
}

/// Get all groups for on organization.
pub async fn org_groups(&self, org_name: &str) -> Result<OrgGroupsResponse> {
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<Vec<UserToken>> {
let url = endpoints::list_tokens(&self.config.connection.uri)?;
Expand Down
2 changes: 1 addition & 1 deletion cli/src/bin/phylum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 85 additions & 11 deletions cli/src/commands/group.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("group").unwrap();

Expand All @@ -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::<String>("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::<String>("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);

Expand All @@ -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::<String>("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)
}
Expand Down Expand Up @@ -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<String>,
pub name: String,
}
41 changes: 31 additions & 10 deletions cli/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -203,16 +203,37 @@ impl Format for GetProjectResponse {
}
}

impl Format for ListUserGroupsResponse {
impl Format for Vec<ListGroupsEntry> {
fn pretty<W: Write>(&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::<fn(&UserGroup) -> 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 <NAME>`."
);
return;
}

// Use condensed format if only legacy groups are present.
if self.iter().all(|group| group.org.is_none()) {
let table = format_table::<fn(&ListGroupsEntry) -> String, _>(self, &[(
"Group Name",
|group| print::truncate(&group.name, MAX_NAME_WIDTH).into_owned(),
)]);
let _ = writeln!(writer, "{table}");
} else {
let table = format_table::<fn(&ListGroupsEntry) -> 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}");
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions cli/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,15 @@ pub struct GetProjectResponse {
pub default_label: Option<String>,
pub repository_url: Option<String>,
}

/// Response body for Phylum's GET /organizations/<org>/groups endpoint.
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)]
pub struct OrgGroupsResponse {
pub groups: Vec<OrgGroup>,
}

/// Group returned by Phylum's GET /organizations/<org>/groups endpoint.
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)]
pub struct OrgGroup {
pub name: String,
}
8 changes: 8 additions & 0 deletions doc_templates/phylum_group_create.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
8 changes: 8 additions & 0 deletions doc_templates/phylum_group_delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
3 changes: 3 additions & 0 deletions doc_templates/phylum_group_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
8 changes: 8 additions & 0 deletions docs/commands/phylum_group_create.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ Usage: phylum group create [OPTIONS] <GROUP_NAME>
```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
```
8 changes: 8 additions & 0 deletions docs/commands/phylum_group_delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ Usage: phylum group delete [OPTIONS] <GROUP_NAME>
```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
```
3 changes: 3 additions & 0 deletions docs/commands/phylum_group_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

0 comments on commit 5aec014

Please sign in to comment.