Skip to content

Commit 909acbc

Browse files
authored
feat: add sharing add command to the CLI (coder#19576)
Adds a `sharing add` command for sharing Workspaces with other users and groups. The command allows sharing with multiple users, and groups within one command as well as specifying the role (`use`, or `admin`) defaulting to `use` if none is specified. In the current implementation when the command completes we show the user the current state of the workspace ACL. ``` $ coder sharing add apricot-catfish-86 --user=member:admin --group=contractors:use USER GROUP ROLE member - admin member contractors use ``` If a user is a part of multiple groups, or the workspace has been individually shared with them they will show up multiple times. Although this is a bit confusing at first glance it's important to be able to tell what the maximum role a user may have, and via what ACL they have it. --- One piece of UX to consider is that in order to be able to share a Workspace with a user they must have a role that can read that user. In the tests we give the user the `ScopedRoleOrgAuditor` role. Closes [coder/internal#859](coder/internal#859)
1 parent a78d65c commit 909acbc

File tree

4 files changed

+609
-0
lines changed

4 files changed

+609
-0
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
9797
r.portForward(),
9898
r.publickey(),
9999
r.resetPassword(),
100+
r.sharing(),
100101
r.state(),
101102
r.templates(),
102103
r.tokens(),

cli/sharing.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
7+
"golang.org/x/xerrors"
8+
9+
"github.com/coder/coder/v2/cli/cliui"
10+
"github.com/coder/coder/v2/codersdk"
11+
"github.com/coder/serpent"
12+
)
13+
14+
const defaultGroupDisplay = "-"
15+
16+
type workspaceShareRow struct {
17+
User string `table:"user"`
18+
Group string `table:"group,default_sort"`
19+
Role codersdk.WorkspaceRole `table:"role"`
20+
}
21+
22+
func (r *RootCmd) sharing() *serpent.Command {
23+
orgContext := NewOrganizationContext()
24+
25+
cmd := &serpent.Command{
26+
Use: "sharing [subcommand]",
27+
Short: "Commands for managing shared workspaces",
28+
Aliases: []string{"share"},
29+
Handler: func(inv *serpent.Invocation) error {
30+
return inv.Command.HelpHandler(inv)
31+
},
32+
Children: []*serpent.Command{r.shareWorkspace(orgContext)},
33+
Hidden: true,
34+
}
35+
36+
orgContext.AttachOptions(cmd)
37+
return cmd
38+
}
39+
40+
func (r *RootCmd) shareWorkspace(orgContext *OrganizationContext) *serpent.Command {
41+
var (
42+
// Username regex taken from codersdk/name.go
43+
nameRoleRegex = regexp.MustCompile(`(^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*)+(?::([A-Za-z0-9-]+))?`)
44+
client = new(codersdk.Client)
45+
users []string
46+
groups []string
47+
formatter = cliui.NewOutputFormatter(
48+
cliui.TableFormat(
49+
[]workspaceShareRow{}, []string{"User", "Group", "Role"}),
50+
cliui.JSONFormat(),
51+
)
52+
)
53+
54+
cmd := &serpent.Command{
55+
Use: "add <workspace> --user <user>:<role> --group <group>:<role>",
56+
Aliases: []string{"share"},
57+
Short: "Share a workspace with a user or group.",
58+
Options: serpent.OptionSet{
59+
{
60+
Name: "user",
61+
Description: "A comma separated list of users to share the workspace with.",
62+
Flag: "user",
63+
Value: serpent.StringArrayOf(&users),
64+
}, {
65+
Name: "group",
66+
Description: "A comma separated list of groups to share the workspace with.",
67+
Flag: "group",
68+
Value: serpent.StringArrayOf(&groups),
69+
},
70+
},
71+
Middleware: serpent.Chain(
72+
r.InitClient(client),
73+
serpent.RequireNArgs(1),
74+
),
75+
Handler: func(inv *serpent.Invocation) error {
76+
if len(users) == 0 && len(groups) == 0 {
77+
return xerrors.New("at least one user or group must be provided")
78+
}
79+
80+
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
81+
if err != nil {
82+
return xerrors.Errorf("could not fetch the workspace %s: %w", inv.Args[0], err)
83+
}
84+
85+
org, err := orgContext.Selected(inv, client)
86+
if err != nil {
87+
return err
88+
}
89+
90+
userRoles := make(map[string]codersdk.WorkspaceRole, len(users))
91+
if len(users) > 0 {
92+
orgMembers, err := client.OrganizationMembers(inv.Context(), org.ID)
93+
if err != nil {
94+
return err
95+
}
96+
97+
for _, user := range users {
98+
userAndRole := nameRoleRegex.FindStringSubmatch(user)
99+
if userAndRole == nil {
100+
return xerrors.Errorf("invalid user format %q: must match pattern 'username:role'", user)
101+
}
102+
103+
username := userAndRole[1]
104+
role := userAndRole[2]
105+
if role == "" {
106+
role = string(codersdk.WorkspaceRoleUse)
107+
}
108+
109+
userID := ""
110+
for _, member := range orgMembers {
111+
if member.Username == username {
112+
userID = member.UserID.String()
113+
break
114+
}
115+
}
116+
if userID == "" {
117+
return xerrors.Errorf("could not find user %s in the organization %s", username, org.Name)
118+
}
119+
120+
workspaceRole, err := stringToWorkspaceRole(role)
121+
if err != nil {
122+
return err
123+
}
124+
125+
userRoles[userID] = workspaceRole
126+
}
127+
}
128+
129+
groupRoles := make(map[string]codersdk.WorkspaceRole)
130+
if len(groups) > 0 {
131+
orgGroups, err := client.Groups(inv.Context(), codersdk.GroupArguments{
132+
Organization: org.ID.String(),
133+
})
134+
if err != nil {
135+
return err
136+
}
137+
138+
for _, group := range groups {
139+
groupAndRole := nameRoleRegex.FindStringSubmatch(group)
140+
if groupAndRole == nil {
141+
return xerrors.Errorf("invalid group format %q: must match pattern 'group:role'", group)
142+
}
143+
groupName := groupAndRole[1]
144+
role := groupAndRole[2]
145+
if role == "" {
146+
role = string(codersdk.WorkspaceRoleUse)
147+
}
148+
149+
var orgGroup *codersdk.Group
150+
for _, group := range orgGroups {
151+
if group.Name == groupName {
152+
orgGroup = &group
153+
break
154+
}
155+
}
156+
157+
if orgGroup == nil {
158+
return xerrors.Errorf("could not find group named %s belonging to the organization %s", groupName, org.Name)
159+
}
160+
161+
workspaceRole, err := stringToWorkspaceRole(role)
162+
if err != nil {
163+
return err
164+
}
165+
166+
groupRoles[orgGroup.ID.String()] = workspaceRole
167+
}
168+
}
169+
170+
err = client.UpdateWorkspaceACL(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceACL{
171+
UserRoles: userRoles,
172+
GroupRoles: groupRoles,
173+
})
174+
if err != nil {
175+
return err
176+
}
177+
178+
workspaceACL, err := client.WorkspaceACL(inv.Context(), workspace.ID)
179+
if err != nil {
180+
return xerrors.Errorf("could not fetch current workspace ACL after sharing %w", err)
181+
}
182+
183+
outputRows := make([]workspaceShareRow, 0)
184+
for _, user := range workspaceACL.Users {
185+
if user.Role == codersdk.WorkspaceRoleDeleted {
186+
continue
187+
}
188+
189+
outputRows = append(outputRows, workspaceShareRow{
190+
User: user.Username,
191+
Group: defaultGroupDisplay,
192+
Role: user.Role,
193+
})
194+
}
195+
for _, group := range workspaceACL.Groups {
196+
if group.Role == codersdk.WorkspaceRoleDeleted {
197+
continue
198+
}
199+
200+
for _, user := range group.Members {
201+
outputRows = append(outputRows, workspaceShareRow{
202+
User: user.Username,
203+
Group: group.Name,
204+
Role: group.Role,
205+
})
206+
}
207+
}
208+
out, err := formatter.Format(inv.Context(), outputRows)
209+
if err != nil {
210+
return err
211+
}
212+
213+
_, err = fmt.Fprintln(inv.Stdout, out)
214+
return err
215+
},
216+
}
217+
218+
return cmd
219+
}
220+
221+
func stringToWorkspaceRole(role string) (codersdk.WorkspaceRole, error) {
222+
switch role {
223+
case string(codersdk.WorkspaceRoleUse):
224+
return codersdk.WorkspaceRoleUse, nil
225+
case string(codersdk.WorkspaceRoleAdmin):
226+
return codersdk.WorkspaceRoleAdmin, nil
227+
default:
228+
return "", xerrors.Errorf("invalid role %q: expected %q or %q",
229+
role, codersdk.WorkspaceRoleAdmin, codersdk.WorkspaceRoleUse)
230+
}
231+
}

0 commit comments

Comments
 (0)