Skip to content

Commit a70d1e7

Browse files
committed
feat: add cli command to edit custom roles
1 parent 3ac8a72 commit a70d1e7

File tree

4 files changed

+275
-8
lines changed

4 files changed

+275
-8
lines changed

cli/cliui/parameter.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
4343
return "", err
4444
}
4545

46-
values, err := MultiSelect(inv, options)
46+
values, err := MultiSelect(inv, MultiSelectOptions{
47+
Options: options,
48+
Defaults: options,
49+
})
4750
if err == nil {
4851
v, err := json.Marshal(&values)
4952
if err != nil {

cli/cliui/select.go

+16-6
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func init() {
2121
{{- .CurrentOpt.Value}}
2222
{{- color "reset"}}
2323
{{end}}
24-
24+
{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }}
2525
{{- if not .ShowAnswer }}
2626
{{- if .Config.Icons.Help.Text }}
2727
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
@@ -44,18 +44,20 @@ func init() {
4444
{{- " "}}{{- .CurrentOpt.Value}}
4545
{{end}}
4646
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
47+
{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }}
4748
{{- if not .ShowAnswer }}
4849
{{- "\n"}}
4950
{{- range $ix, $option := .PageEntries}}
5051
{{- template "option" $.IterateOption $ix $option}}
5152
{{- end}}
52-
{{- end}}`
53+
{{- end }}`
5354
}
5455

5556
type SelectOptions struct {
5657
Options []string
5758
// Default will be highlighted first if it's a valid option.
5859
Default string
60+
Message string
5961
Size int
6062
HideSearch bool
6163
}
@@ -122,6 +124,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
122124
Options: opts.Options,
123125
Default: defaultOption,
124126
PageSize: opts.Size,
127+
Message: opts.Message,
125128
}, &value, survey.WithIcons(func(is *survey.IconSet) {
126129
is.Help.Text = "Type to search"
127130
if opts.HideSearch {
@@ -138,15 +141,22 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
138141
return value, err
139142
}
140143

141-
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
144+
type MultiSelectOptions struct {
145+
Message string
146+
Options []string
147+
Defaults []string
148+
}
149+
150+
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
142151
// Similar hack is applied to Select()
143152
if flag.Lookup("test.v") != nil {
144-
return items, nil
153+
return opts.Defaults, nil
145154
}
146155

147156
prompt := &survey.MultiSelect{
148-
Options: items,
149-
Default: items,
157+
Message: opts.Message,
158+
Options: opts.Options,
159+
Default: opts.Defaults,
150160
}
151161

152162
var values []string

cli/cliui/select_test.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
107107
var values []string
108108
cmd := &serpent.Command{
109109
Handler: func(inv *serpent.Invocation) error {
110-
selectedItems, err := cliui.MultiSelect(inv, items)
110+
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
111+
Options: items,
112+
Defaults: items,
113+
})
111114
if err == nil {
112115
values = selectedItems
113116
}

enterprise/cli/roleedit.go

+251
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"strings"
9+
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/v2/cli"
13+
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/coderd/util/slice"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/serpent"
17+
)
18+
19+
func (r *RootCmd) editRole() *serpent.Command {
20+
formatter := cliui.NewOutputFormatter(
21+
cliui.ChangeFormatterData(
22+
cliui.TableFormat([]codersdk.Role{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}),
23+
func(data any) (any, error) {
24+
return []codersdk.Role{data.(codersdk.Role)}, nil
25+
},
26+
),
27+
cliui.JSONFormat(),
28+
)
29+
30+
var (
31+
dryRun bool
32+
)
33+
34+
client := new(codersdk.Client)
35+
cmd := &serpent.Command{
36+
Use: "edit <role_name>",
37+
Short: "Edit a custom role",
38+
Long: cli.FormatExamples(
39+
cli.Example{
40+
Description: "Run with an input.json file",
41+
Command: "coder roles edit custom_name < role.json",
42+
},
43+
),
44+
Options: []serpent.Option{
45+
cliui.SkipPromptOption(),
46+
{
47+
Name: "dry-run",
48+
Description: "Does all the work, but does not submit the final updated role.",
49+
Flag: "dry-run",
50+
Value: serpent.BoolOf(&dryRun),
51+
},
52+
},
53+
Middleware: serpent.Chain(
54+
serpent.RequireNArgs(1),
55+
r.InitClient(client),
56+
),
57+
Handler: func(inv *serpent.Invocation) error {
58+
ctx := inv.Context()
59+
roles, err := client.ListSiteRoles(ctx)
60+
if err != nil {
61+
return xerrors.Errorf("listing roles: %w", err)
62+
}
63+
64+
// Make sure the role actually exists first
65+
var originalRole codersdk.AssignableRoles
66+
for _, r := range roles {
67+
if strings.EqualFold(inv.Args[0], r.Name) {
68+
originalRole = r
69+
break
70+
}
71+
}
72+
73+
if originalRole.Name == "" {
74+
_, err = cliui.Prompt(inv, cliui.PromptOptions{
75+
Text: "No role exists with that name, do you want to create one?",
76+
Default: "yes",
77+
IsConfirm: true,
78+
})
79+
if err != nil {
80+
return xerrors.Errorf("abort: %w", err)
81+
}
82+
83+
originalRole.Role = codersdk.Role{
84+
Name: inv.Args[0],
85+
}
86+
}
87+
88+
var customRole *codersdk.Role
89+
// Either interactive, or take input mode.
90+
fi, _ := os.Stdin.Stat()
91+
if (fi.Mode() & os.ModeCharDevice) == 0 {
92+
bytes, err := io.ReadAll(os.Stdin)
93+
if err != nil {
94+
return xerrors.Errorf("reading stdin: %w", err)
95+
}
96+
97+
err = json.Unmarshal(bytes, customRole)
98+
if err != nil {
99+
return xerrors.Errorf("parsing stdin json: %w", err)
100+
}
101+
} else {
102+
// Interactive mode
103+
if len(originalRole.OrganizationPermissions) > 0 {
104+
return xerrors.Errorf("unable to edit role in interactive mode, it contains organization permissions")
105+
}
106+
107+
if len(originalRole.UserPermissions) > 0 {
108+
return xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
109+
}
110+
111+
customRole, err = interactiveEdit(inv, &originalRole.Role)
112+
if err != nil {
113+
return xerrors.Errorf("editing role: %w", err)
114+
}
115+
}
116+
117+
totalOrg := 0
118+
for _, o := range customRole.OrganizationPermissions {
119+
totalOrg += len(o)
120+
}
121+
preview := fmt.Sprintf("perms: %d site, %d over %d orgs, %d user",
122+
len(customRole.SitePermissions), totalOrg, len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
123+
_, err = cliui.Prompt(inv, cliui.PromptOptions{
124+
Text: "Are you sure you wish to update the role? " + preview,
125+
Default: "yes",
126+
IsConfirm: true,
127+
})
128+
if err != nil {
129+
return xerrors.Errorf("abort: %w", err)
130+
}
131+
132+
var updated codersdk.Role
133+
if dryRun {
134+
// Do not actually post
135+
updated = *customRole
136+
} else {
137+
updated, err = client.PatchRole(ctx, *customRole)
138+
if err != nil {
139+
return fmt.Errorf("patch role: %w", err)
140+
}
141+
}
142+
143+
_, err = formatter.Format(ctx, updated)
144+
if err != nil {
145+
return xerrors.Errorf("formatting: %w", err)
146+
}
147+
return nil
148+
},
149+
}
150+
151+
formatter.AttachOptions(&cmd.Options)
152+
return cmd
153+
}
154+
155+
func interactiveEdit(inv *serpent.Invocation, role *codersdk.Role) (*codersdk.Role, error) {
156+
allowedResources := []codersdk.RBACResource{
157+
codersdk.ResourceTemplate,
158+
codersdk.ResourceWorkspace,
159+
codersdk.ResourceUser,
160+
codersdk.ResourceGroup,
161+
}
162+
163+
const done = "Finish and submit changes"
164+
const abort = "Cancel changes"
165+
166+
// Now starts the role editing "game".
167+
customRoleLoop:
168+
for {
169+
selected, err := cliui.Select(inv, cliui.SelectOptions{
170+
Message: "Select which resources to edit permissions",
171+
Options: append(permissionPreviews(role, allowedResources), done, abort),
172+
})
173+
if err != nil {
174+
return role, xerrors.Errorf("selecting resource: %w", err)
175+
}
176+
switch selected {
177+
case done:
178+
break customRoleLoop
179+
case abort:
180+
return role, xerrors.Errorf("edit role %q aborted", role.Name)
181+
default:
182+
strs := strings.Split(selected, "::")
183+
resource := strings.TrimSpace(strs[0])
184+
185+
actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
186+
Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource),
187+
Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]),
188+
Defaults: defaultActions(role, resource),
189+
})
190+
if err != nil {
191+
return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
192+
}
193+
applyResourceActions(role, resource, actions)
194+
// back to resources!
195+
}
196+
}
197+
// This println is required because the prompt ends us on the same line as some text.
198+
fmt.Println()
199+
200+
return role, nil
201+
}
202+
203+
func applyResourceActions(role *codersdk.Role, resource string, actions []string) {
204+
// Construct new site perms with only new perms for the resource
205+
keep := make([]codersdk.Permission, 0)
206+
for _, perm := range role.SitePermissions {
207+
perm := perm
208+
if string(perm.ResourceType) != resource {
209+
keep = append(keep, perm)
210+
}
211+
}
212+
213+
// Add new perms
214+
for _, action := range actions {
215+
keep = append(keep, codersdk.Permission{
216+
Negate: false,
217+
ResourceType: codersdk.RBACResource(resource),
218+
Action: codersdk.RBACAction(action),
219+
})
220+
}
221+
222+
role.SitePermissions = keep
223+
}
224+
225+
func defaultActions(role *codersdk.Role, resource string) []string {
226+
defaults := make([]string, 0)
227+
for _, perm := range role.SitePermissions {
228+
if string(perm.ResourceType) == resource {
229+
defaults = append(defaults, string(perm.Action))
230+
}
231+
}
232+
return defaults
233+
}
234+
235+
func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string {
236+
previews := make([]string, 0, len(resources))
237+
for _, resource := range resources {
238+
previews = append(previews, permissionPreview(role, resource))
239+
}
240+
return previews
241+
}
242+
243+
func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string {
244+
count := 0
245+
for _, perm := range role.SitePermissions {
246+
if perm.ResourceType == resource {
247+
count++
248+
}
249+
}
250+
return fmt.Sprintf("%s :: %d permissions", resource, count)
251+
}

0 commit comments

Comments
 (0)