diff --git a/cli/create.go b/cli/create.go index 793f4d99b4839..4b268f7161d8f 100644 --- a/cli/create.go +++ b/cli/create.go @@ -33,7 +33,7 @@ func create() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/login.go b/cli/login.go index 22a8f621823fb..2955983ad9ec7 100644 --- a/cli/login.go +++ b/cli/login.go @@ -86,7 +86,7 @@ func login() *cobra.Command { return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err) } if !hasInitialUser { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Your Coder deployment hasn't been set up!\n") if username == "" { if !isTTY(cmd) { @@ -244,7 +244,7 @@ func login() *cobra.Command { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username)) return nil }, } diff --git a/cli/logout.go b/cli/logout.go index e0d01b306d0e5..d40a9ef45940c 100644 --- a/cli/logout.go +++ b/cli/logout.go @@ -67,7 +67,7 @@ func logout() *cobra.Command { errorString := strings.TrimRight(errorStringBuilder.String(), "\n") return xerrors.New("Failed to log out.\n" + errorString) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"You are no longer logged in. You can log in using 'coder login '.\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login '.\n") return nil }, } diff --git a/cli/parameterslist.go b/cli/parameterslist.go index 438b15acea419..3978df26a850f 100644 --- a/cli/parameterslist.go +++ b/cli/parameterslist.go @@ -27,7 +27,7 @@ func parameterList() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/root.go b/cli/root.go index a68f5522b9cdd..dcff07ab1f7df 100644 --- a/cli/root.go +++ b/cli/root.go @@ -30,7 +30,7 @@ import ( ) var ( - caret = cliui.Styles.Prompt.String() + Caret = cliui.Styles.Prompt.String() // Applied as annotations to workspace commands // so they display in a separated "help" section. @@ -352,8 +352,8 @@ func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) { return client, nil } -// currentOrganization returns the currently active organization for the authenticated user. -func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) { +// CurrentOrganization returns the currently active organization for the authenticated user. +func CurrentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) { orgs, err := client.OrganizationsByUser(cmd.Context(), codersdk.Me) if err != nil { return codersdk.Organization{}, nil diff --git a/cli/templatecreate.go b/cli/templatecreate.go index de0b8eab8f27e..7c0d25f0f7e2f 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -40,7 +40,7 @@ func templateCreate() *cobra.Command { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templatedelete.go b/cli/templatedelete.go index 8b1b1903a9b68..230bb4bc2662d 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -27,7 +27,7 @@ func templateDelete() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templateedit.go b/cli/templateedit.go index e48b509a42484..e0e4cf57a7196 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -29,7 +29,7 @@ func templateEdit() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/templatelist.go b/cli/templatelist.go index b7dc29ac497fb..e528687a7459b 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -20,7 +20,7 @@ func templateList() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } @@ -30,7 +30,7 @@ func templateList() *cobra.Command { } if len(templates) == 0 { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No templates found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name)) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No templates found in %s! Create one:\n\n", Caret, color.HiWhiteString(organization.Name)) _, _ = fmt.Fprintln(cmd.ErrOrStderr(), color.HiMagentaString(" $ coder templates create \n")) return nil } diff --git a/cli/templatepull.go b/cli/templatepull.go index 09f70c91b8f9e..19e5e04d82900 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -35,7 +35,7 @@ func templatePull() *cobra.Command { } // TODO(JonA): Do we need to add a flag for organization? - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("current organization: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index 40bafed0ef00c..9eed180667e7c 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -34,7 +34,7 @@ func templatePush() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/cli/templateversions.go b/cli/templateversions.go index c5111d0a16e17..3b13aea8f2c7d 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -45,7 +45,7 @@ func templateVersionsList() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) } diff --git a/cli/usercreate.go b/cli/usercreate.go index 73bc0fb8f9947..dd65282202060 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -25,7 +25,7 @@ func userCreate() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) + organization, err := CurrentOrganization(cmd, client) if err != nil { return err } diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go index 13328cbcf1552..a513f811a6916 100644 --- a/coderd/httpmw/groupparam.go +++ b/coderd/httpmw/groupparam.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -24,6 +25,42 @@ func GroupParam(r *http.Request) database.Group { return group } +func ExtractGroupByNameParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + org = OrganizationParam(r) + ) + + name := chi.URLParam(r, "groupName") + if name == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Missing group name in URL", + }) + return + } + + group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: org.ID, + Name: name, + }) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + ctx = context.WithValue(ctx, groupParamContextKey{}, group) + chi.RouteContext(ctx).URLParams.Add("organization", group.OrganizationID.String()) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + // ExtraGroupParam grabs a group from the "group" URL parameter. func ExtractGroupParam(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/codersdk/groups.go b/codersdk/groups.go index a84f8560b2440..340fffaddd859 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -58,6 +58,23 @@ func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]G return groups, json.NewDecoder(res.Body).Decode(&groups) } +func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name string) (Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/groups/%s", orgID.String(), name), + nil, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/groups/%s", group.String()), diff --git a/enterprise/cli/groupcreate.go b/enterprise/cli/groupcreate.go new file mode 100644 index 0000000000000..1a48d4af0810b --- /dev/null +++ b/enterprise/cli/groupcreate.go @@ -0,0 +1,54 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func groupCreate() *cobra.Command { + var ( + avatarURL string + ) + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a user group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.CreateGroup(ctx, org.ID, codersdk.CreateGroupRequest{ + Name: args[0], + AvatarURL: avatarURL, + }) + if err != nil { + return xerrors.Errorf("create group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully created group %s!\n", cliui.Styles.Keyword.Render(group.Name)) + return nil + }, + } + + cliflag.StringVarP(cmd.Flags(), &avatarURL, "avatar-url", "u", "CODER_AVATAR_URL", "", "set an avatar for a group") + + return cmd +} diff --git a/enterprise/cli/groupcreate_test.go b/enterprise/cli/groupcreate_test.go new file mode 100644 index 0000000000000..2a0e61c6c0687 --- /dev/null +++ b/enterprise/cli/groupcreate_test.go @@ -0,0 +1,48 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" +) + +func TestCreateGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + var ( + groupName = "test" + avatarURL = "https://example.com" + ) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", + "create", groupName, + "--avatar-url", avatarURL, + ) + + pty := ptytest.New(t) + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully created group %s!", cliui.Styles.Keyword.Render(groupName))) + }) +} diff --git a/enterprise/cli/groupdelete.go b/enterprise/cli/groupdelete.go new file mode 100644 index 0000000000000..687d05c401c0c --- /dev/null +++ b/enterprise/cli/groupdelete.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" +) + +func groupDelete() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a user group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + groupName = args[0] + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.GroupByOrgAndName(ctx, org.ID, groupName) + if err != nil { + return xerrors.Errorf("group by org and name: %w", err) + } + + err = client.DeleteGroup(ctx, group.ID) + if err != nil { + return xerrors.Errorf("delete group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully deleted group %s!\n", cliui.Styles.Keyword.Render(group.Name)) + return nil + }, + } + + return cmd +} diff --git a/enterprise/cli/groupdelete_test.go b/enterprise/cli/groupdelete_test.go new file mode 100644 index 0000000000000..e2319a43e2aaa --- /dev/null +++ b/enterprise/cli/groupdelete_test.go @@ -0,0 +1,71 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupDelete(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "delete", group.Name, + ) + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully deleted group %s", cliui.Styles.Keyword.Render(group.Name))) + }) + + t.Run("NoArg", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "delete") + + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.Error(t, err) + }) +} diff --git a/enterprise/cli/groupedit.go b/enterprise/cli/groupedit.go new file mode 100644 index 0000000000000..d2881e98a6afb --- /dev/null +++ b/enterprise/cli/groupedit.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "net/mail" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func groupEdit() *cobra.Command { + var ( + avatarURL string + name string + addUsers []string + rmUsers []string + ) + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit a user group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + groupName = args[0] + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + group, err := client.GroupByOrgAndName(ctx, org.ID, groupName) + if err != nil { + return xerrors.Errorf("group by org and name: %w", err) + } + + req := codersdk.PatchGroupRequest{ + Name: name, + } + + if avatarURL != "" { + req.AvatarURL = &avatarURL + } + + users, err := client.Users(ctx, codersdk.UsersRequest{}) + if err != nil { + return xerrors.Errorf("get users: %w", err) + } + + req.AddUsers, err = convertToUserIDs(addUsers, users) + if err != nil { + return xerrors.Errorf("parse add-users: %w", err) + } + + req.RemoveUsers, err = convertToUserIDs(rmUsers, users) + if err != nil { + return xerrors.Errorf("parse rm-users: %w", err) + } + + group, err = client.PatchGroup(ctx, group.ID, req) + if err != nil { + return xerrors.Errorf("patch group: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Successfully patched group %s!\n", cliui.Styles.Keyword.Render(group.Name)) + return nil + }, + } + + cliflag.StringVarP(cmd.Flags(), &name, "name", "n", "", "", "Update the group name") + cliflag.StringVarP(cmd.Flags(), &avatarURL, "avatar-url", "u", "", "", "Update the group avatar") + cliflag.StringArrayVarP(cmd.Flags(), &addUsers, "add-users", "a", "", nil, "Add users to the group. Accepts emails or IDs.") + cliflag.StringArrayVarP(cmd.Flags(), &rmUsers, "rm-users", "r", "", nil, "Remove users to the group. Accepts emails or IDs.") + return cmd +} + +// convertToUserIDs accepts a list of users in the form of IDs or email addresses +// and translates any emails to the matching user ID. +func convertToUserIDs(userList []string, users []codersdk.User) ([]string, error) { + converted := make([]string, 0, len(userList)) + + for _, user := range userList { + if _, err := uuid.Parse(user); err == nil { + converted = append(converted, user) + continue + } + if _, err := mail.ParseAddress(user); err == nil { + for _, u := range users { + if u.Email == user { + converted = append(converted, u.ID.String()) + break + } + } + continue + } + + return nil, xerrors.Errorf("%q must be a valid UUID or email address", user) + } + + return converted, nil +} diff --git a/enterprise/cli/groupedit_test.go b/enterprise/cli/groupedit_test.go new file mode 100644 index 0000000000000..8c4c8f0f16e49 --- /dev/null +++ b/enterprise/cli/groupedit_test.go @@ -0,0 +1,119 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupEdit(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + // We use the sdk here as opposed to the CLI since adding this user + // is considered setup. They will be removed in the proper CLI test. + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user3.ID.String()}, + }) + require.NoError(t, err) + + var ( + expectedName = "beta" + ) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "edit", group.Name, + "--name", expectedName, + "--avatar-url", "https://example.com", + "-a", user1.ID.String(), + "-a", user2.Email, + "-r", user3.ID.String(), + ) + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch(fmt.Sprintf("Successfully patched group %s", cliui.Styles.Keyword.Render(expectedName))) + }) + + t.Run("InvalidUserInput", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "groups", "edit", group.Name, + "-a", "foo", + ) + + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "must be a valid UUID or email address") + }) + + t.Run("NoArg", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "edit") + + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.Error(t, err) + }) +} diff --git a/enterprise/cli/grouplist.go b/enterprise/cli/grouplist.go new file mode 100644 index 0000000000000..5fab5a26132dd --- /dev/null +++ b/enterprise/cli/grouplist.go @@ -0,0 +1,82 @@ +package cli + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func groupList() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List user groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + ) + + client, err := agpl.CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + org, err := agpl.CurrentOrganization(cmd, client) + if err != nil { + return xerrors.Errorf("current organization: %w", err) + } + + groups, err := client.GroupsByOrganization(ctx, org.ID) + if err != nil { + return xerrors.Errorf("get groups: %w", err) + } + + if len(groups) == 0 { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s No groups found in %s! Create one:\n\n", agpl.Caret, color.HiWhiteString(org.Name)) + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), color.HiMagentaString(" $ coder groups create \n")) + return nil + } + + out, err := displayGroups(groups...) + if err != nil { + return xerrors.Errorf("display groups: %w", err) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), out) + return nil + }, + } + return cmd +} + +type groupTableRow struct { + Name string `table:"name"` + OrganizationID uuid.UUID `table:"organization_id"` + Members []string `table:"members"` + AvatarURL string `table:"avatar_url"` +} + +func displayGroups(groups ...codersdk.Group) (string, error) { + rows := make([]groupTableRow, 0, len(groups)) + for _, group := range groups { + members := make([]string, 0, len(group.Members)) + for _, member := range group.Members { + members = append(members, member.Email) + } + rows = append(rows, groupTableRow{ + Name: group.Name, + OrganizationID: group.OrganizationID, + AvatarURL: group.AvatarURL, + Members: members, + }) + } + + return cliui.DisplayTable(rows, "name", nil) +} diff --git a/enterprise/cli/grouplist_test.go b/enterprise/cli/grouplist_test.go new file mode 100644 index 0000000000000..8740829a017c9 --- /dev/null +++ b/enterprise/cli/grouplist_test.go @@ -0,0 +1,100 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestGroupList(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + ctx, _ := testutil.Context(t) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, admin.OrganizationID) + + // We intentionally create the first group as beta so that we + // can assert that things are being sorted by name intentionally + // and not by chance (or some other parameter like created_at). + group1, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "beta", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "alpha", + }) + require.NoError(t, err) + + _, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String()}, + }) + require.NoError(t, err) + + _, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String()}, + }) + require.NoError(t, err) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "list") + + pty := ptytest.New(t) + + cmd.SetOut(pty.Output()) + clitest.SetupConfig(t, client, root) + + err = cmd.Execute() + require.NoError(t, err) + + matches := []string{"NAME", "ORGANIZATION ID", "MEMBERS", " AVATAR URL", + group2.Name, group2.OrganizationID.String(), user2.Email, group2.AvatarURL, + group1.Name, group1.OrganizationID.String(), user1.Email, group1.AvatarURL, + } + + for _, match := range matches { + pty.ExpectMatch(match) + } + }) + + t.Run("NoGroups", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), "groups", "list") + + pty := ptytest.New(t) + + cmd.SetErr(pty.Output()) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.NoError(t, err) + + pty.ExpectMatch("No groups found") + pty.ExpectMatch("coder groups create ") + }) +} diff --git a/enterprise/cli/groups.go b/enterprise/cli/groups.go new file mode 100644 index 0000000000000..d2acdb3d527bb --- /dev/null +++ b/enterprise/cli/groups.go @@ -0,0 +1,23 @@ +package cli + +import "github.com/spf13/cobra" + +func groups() *cobra.Command { + cmd := &cobra.Command{ + Use: "groups", + Short: "Manage groups", + Aliases: []string{"group"}, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + groupCreate(), + groupList(), + groupEdit(), + groupDelete(), + ) + + return cmd +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 52decb3266226..41337f14c77dd 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -11,6 +11,7 @@ func enterpriseOnly() []*cobra.Command { server(), features(), licenses(), + groups(), } } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index a404159e51962..69f09c6b9f5d6 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -77,10 +77,18 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/organizations/{organization}/groups", func(r chi.Router) { r.Use( apiKeyMiddleware, + api.templateRBACEnabledMW, httpmw.ExtractOrganizationParam(api.Database), ) r.Post("/", api.postGroupByOrganization) r.Get("/", api.groups) + r.Route("/{groupName}", func(r chi.Router) { + r.Use( + httpmw.ExtractGroupByNameParam(api.Database), + ) + + r.Get("/", api.group) + }) }) r.Route("/templates/{template}/acl", func(r chi.Router) { diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index e8ad88cd02805..319c805163271 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -44,6 +44,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { a := coderdtest.NewAuthTester(ctx, t, client, api.AGPL, admin) a.URLParams["licenses/{id}"] = fmt.Sprintf("licenses/%d", license.ID) a.URLParams["groups/{group}"] = fmt.Sprintf("groups/%s", group.ID.String()) + a.URLParams["{groupName}"] = group.Name skipRoutes, assertRoute := coderdtest.AGPLRoutes(a) assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{ @@ -79,7 +80,11 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionRead, AssertObject: groupObj, } - assertRoute["PATCH:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ + assertRoute["GET:/api/v2/organizations/{organization}/groups/{groupName}"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + } + assertRoute["GET:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ AssertAction: rbac.ActionRead, AssertObject: groupObj, } diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 0cbec41be2a9f..2f800c1ad41ce 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -420,6 +420,26 @@ func TestGroup(t *testing.T) { require.Equal(t, group, ggroup) }) + t.Run("ByName", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client.GroupByOrgAndName(ctx, group.OrganizationID, group.Name) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + t.Run("WithUsers", func(t *testing.T) { t.Parallel()