diff --git a/cli/root.go b/cli/root.go index b3e67a46ad463..8208d0eb3f639 100644 --- a/cli/root.go +++ b/cli/root.go @@ -99,6 +99,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.portForward(), r.publickey(), r.resetPassword(), + r.sharing(), r.state(), r.templates(), r.tokens(), diff --git a/cli/sharing.go b/cli/sharing.go new file mode 100644 index 0000000000000..f824a0a4c8e20 --- /dev/null +++ b/cli/sharing.go @@ -0,0 +1,231 @@ +package cli + +import ( + "fmt" + "regexp" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +const defaultGroupDisplay = "-" + +type workspaceShareRow struct { + User string `table:"user"` + Group string `table:"group,default_sort"` + Role codersdk.WorkspaceRole `table:"role"` +} + +func (r *RootCmd) sharing() *serpent.Command { + orgContext := NewOrganizationContext() + + cmd := &serpent.Command{ + Use: "sharing [subcommand]", + Short: "Commands for managing shared workspaces", + Aliases: []string{"share"}, + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{r.shareWorkspace(orgContext)}, + Hidden: true, + } + + orgContext.AttachOptions(cmd) + return cmd +} + +func (r *RootCmd) shareWorkspace(orgContext *OrganizationContext) *serpent.Command { + var ( + // Username regex taken from codersdk/name.go + nameRoleRegex = regexp.MustCompile(`(^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*)+(?::([A-Za-z0-9-]+))?`) + client = new(codersdk.Client) + users []string + groups []string + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []workspaceShareRow{}, []string{"User", "Group", "Role"}), + cliui.JSONFormat(), + ) + ) + + cmd := &serpent.Command{ + Use: "add --user : --group :", + Aliases: []string{"share"}, + Short: "Share a workspace with a user or group.", + Options: serpent.OptionSet{ + { + Name: "user", + Description: "A comma separated list of users to share the workspace with.", + Flag: "user", + Value: serpent.StringArrayOf(&users), + }, { + Name: "group", + Description: "A comma separated list of groups to share the workspace with.", + Flag: "group", + Value: serpent.StringArrayOf(&groups), + }, + }, + Middleware: serpent.Chain( + r.InitClient(client), + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + if len(users) == 0 && len(groups) == 0 { + return xerrors.New("at least one user or group must be provided") + } + + workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) + if err != nil { + return xerrors.Errorf("could not fetch the workspace %s: %w", inv.Args[0], err) + } + + org, err := orgContext.Selected(inv, client) + if err != nil { + return err + } + + userRoles := make(map[string]codersdk.WorkspaceRole, len(users)) + if len(users) > 0 { + orgMembers, err := client.OrganizationMembers(inv.Context(), org.ID) + if err != nil { + return err + } + + for _, user := range users { + userAndRole := nameRoleRegex.FindStringSubmatch(user) + if userAndRole == nil { + return xerrors.Errorf("invalid user format %q: must match pattern 'username:role'", user) + } + + username := userAndRole[1] + role := userAndRole[2] + if role == "" { + role = string(codersdk.WorkspaceRoleUse) + } + + userID := "" + for _, member := range orgMembers { + if member.Username == username { + userID = member.UserID.String() + break + } + } + if userID == "" { + return xerrors.Errorf("could not find user %s in the organization %s", username, org.Name) + } + + workspaceRole, err := stringToWorkspaceRole(role) + if err != nil { + return err + } + + userRoles[userID] = workspaceRole + } + } + + groupRoles := make(map[string]codersdk.WorkspaceRole) + if len(groups) > 0 { + orgGroups, err := client.Groups(inv.Context(), codersdk.GroupArguments{ + Organization: org.ID.String(), + }) + if err != nil { + return err + } + + for _, group := range groups { + groupAndRole := nameRoleRegex.FindStringSubmatch(group) + if groupAndRole == nil { + return xerrors.Errorf("invalid group format %q: must match pattern 'group:role'", group) + } + groupName := groupAndRole[1] + role := groupAndRole[2] + if role == "" { + role = string(codersdk.WorkspaceRoleUse) + } + + var orgGroup *codersdk.Group + for _, group := range orgGroups { + if group.Name == groupName { + orgGroup = &group + break + } + } + + if orgGroup == nil { + return xerrors.Errorf("could not find group named %s belonging to the organization %s", groupName, org.Name) + } + + workspaceRole, err := stringToWorkspaceRole(role) + if err != nil { + return err + } + + groupRoles[orgGroup.ID.String()] = workspaceRole + } + } + + err = client.UpdateWorkspaceACL(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: userRoles, + GroupRoles: groupRoles, + }) + if err != nil { + return err + } + + workspaceACL, err := client.WorkspaceACL(inv.Context(), workspace.ID) + if err != nil { + return xerrors.Errorf("could not fetch current workspace ACL after sharing %w", err) + } + + outputRows := make([]workspaceShareRow, 0) + for _, user := range workspaceACL.Users { + if user.Role == codersdk.WorkspaceRoleDeleted { + continue + } + + outputRows = append(outputRows, workspaceShareRow{ + User: user.Username, + Group: defaultGroupDisplay, + Role: user.Role, + }) + } + for _, group := range workspaceACL.Groups { + if group.Role == codersdk.WorkspaceRoleDeleted { + continue + } + + for _, user := range group.Members { + outputRows = append(outputRows, workspaceShareRow{ + User: user.Username, + Group: group.Name, + Role: group.Role, + }) + } + } + out, err := formatter.Format(inv.Context(), outputRows) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + return cmd +} + +func stringToWorkspaceRole(role string) (codersdk.WorkspaceRole, error) { + switch role { + case string(codersdk.WorkspaceRoleUse): + return codersdk.WorkspaceRoleUse, nil + case string(codersdk.WorkspaceRoleAdmin): + return codersdk.WorkspaceRoleAdmin, nil + default: + return "", xerrors.Errorf("invalid role %q: expected %q or %q", + role, codersdk.WorkspaceRoleAdmin, codersdk.WorkspaceRoleUse) + } +} diff --git a/cli/sharing_test.go b/cli/sharing_test.go new file mode 100644 index 0000000000000..2da91afa75d16 --- /dev/null +++ b/cli/sharing_test.go @@ -0,0 +1,170 @@ +package cli_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestSharingShare(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + t.Run("ShareWithUsers_Simple", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + orgOwner = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "sharing", "add", workspace.Name, "--org", orgOwner.OrganizationID.String(), "--user", toShareWithUser.Username) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.WorkspaceUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.WorkspaceRole("use"), + }) + + assert.Contains(t, out.String(), toShareWithUser.Username) + assert.Contains(t, out.String(), codersdk.WorkspaceRoleUse) + }) + + t.Run("ShareWithUsers_Multiple", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + orgOwner = coderdtest.CreateFirstUser(t, client) + + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + + _, toShareWithUser1 = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + _, toShareWithUser2 = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, + "sharing", + "add", workspace.Name, "--org", orgOwner.OrganizationID.String(), + fmt.Sprintf("--user=%s,%s", toShareWithUser1.Username, toShareWithUser2.Username), + ) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.WorkspaceUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser1.ID, + Username: toShareWithUser1.Username, + AvatarURL: toShareWithUser1.AvatarURL, + }, + Role: codersdk.WorkspaceRoleUse, + }) + assert.Contains(t, acl.Users, codersdk.WorkspaceUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser2.ID, + Username: toShareWithUser2.Username, + AvatarURL: toShareWithUser2.AvatarURL, + }, + Role: codersdk.WorkspaceRoleUse, + }) + + assert.Contains(t, out.String(), toShareWithUser1.Username) + assert.Contains(t, out.String(), toShareWithUser2.Username) + }) + + t.Run("ShareWithUsers_Roles", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + orgOwner = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv, root := clitest.New(t, "sharing", "add", workspace.Name, + "--org", orgOwner.OrganizationID.String(), + "--user", fmt.Sprintf("%s:admin", toShareWithUser.Username), + ) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Contains(t, acl.Users, codersdk.WorkspaceUser{ + MinimalUser: codersdk.MinimalUser{ + ID: toShareWithUser.ID, + Username: toShareWithUser.Username, + AvatarURL: toShareWithUser.AvatarURL, + }, + Role: codersdk.WorkspaceRoleAdmin, + }) + + found := false + for _, line := range strings.Split(out.String(), "\n") { + if strings.Contains(line, toShareWithUser.Username) && strings.Contains(line, string(codersdk.WorkspaceRoleAdmin)) { + found = true + break + } + } + assert.True(t, found, fmt.Sprintf("expected to find the username %s and role %s in the command: %s", toShareWithUser.Username, codersdk.WorkspaceRoleAdmin, out.String())) + }) +} diff --git a/enterprise/cli/sharing_test.go b/enterprise/cli/sharing_test.go new file mode 100644 index 0000000000000..a03d77412d235 --- /dev/null +++ b/enterprise/cli/sharing_test.go @@ -0,0 +1,207 @@ +package cli_test + +import ( + "bytes" + "context" + "fmt" + "slices" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestSharingShareEnterprise(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + t.Run("ShareWithGroups_Simple", func(t *testing.T) { + t.Parallel() + + var ( + client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, orgMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + group, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "new-group", []uuid.UUID{orgMember.ID}) + require.NoError(t, err) + + inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(), "--group", group.Name) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Len(t, acl.Groups, 1) + assert.Equal(t, acl.Groups[0].Group.ID, group.ID) + assert.Equal(t, acl.Groups[0].Role, codersdk.WorkspaceRoleUse) + + found := false + for _, line := range strings.Split(out.String(), "\n") { + found = strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) + if found { + break + } + } + assert.True(t, found, "Expected to find group name %s and role %s in output: %s", group.Name, codersdk.WorkspaceRoleUse, out.String()) + }) + + t.Run("ShareWithGroups_Multiple", func(t *testing.T) { + t.Parallel() + + var ( + client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + + _, wibbleMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + _, wobbleMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + wibbleGroup, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "wibble", []uuid.UUID{wibbleMember.ID}) + require.NoError(t, err) + + wobbleGroup, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "wobble", []uuid.UUID{wobbleMember.ID}) + require.NoError(t, err) + + inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(), + fmt.Sprintf("--group=%s,%s", wibbleGroup.Name, wobbleGroup.Name)) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Len(t, acl.Groups, 2) + + type workspaceGroup []codersdk.WorkspaceGroup + assert.NotEqual(t, -1, slices.IndexFunc(workspaceGroup(acl.Groups), func(g codersdk.WorkspaceGroup) bool { + return g.Group.ID == wibbleGroup.ID + })) + assert.NotEqual(t, -1, slices.IndexFunc(workspaceGroup(acl.Groups), func(g codersdk.WorkspaceGroup) bool { + return g.Group.ID == wobbleGroup.ID + })) + + t.Run("ShareWithGroups_Role", func(t *testing.T) { + t.Parallel() + + var ( + client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, orgMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + + group, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "new-group", []uuid.UUID{orgMember.ID}) + require.NoError(t, err) + + inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(), "--group", fmt.Sprintf("%s:admin", group.Name)) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID) + require.NoError(t, err) + assert.Len(t, acl.Groups, 1) + assert.Equal(t, acl.Groups[0].Group.ID, group.ID) + assert.Equal(t, acl.Groups[0].Role, codersdk.WorkspaceRoleAdmin) + + found := false + for _, line := range strings.Split(out.String(), "\n") { + found = strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleAdmin)) + if found { + break + } + } + assert.True(t, found, "Expected to find group name %s and role %s in output: %s", group.Name, codersdk.WorkspaceRoleAdmin, out.String()) + }) + }) +} + +func createGroupWithMembers(ctx context.Context, client *codersdk.Client, orgID uuid.UUID, name string, memberIDs []uuid.UUID) (codersdk.Group, error) { + group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{ + Name: name, + DisplayName: name, + }) + if err != nil { + return codersdk.Group{}, err + } + + ids := make([]string, len(memberIDs)) + for i, id := range memberIDs { + ids[i] = id.String() + } + + return client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: ids, + }) +}