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() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy