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

Lines changed: 4 additions & 1 deletion
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

Lines changed: 16 additions & 6 deletions
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

Lines changed: 4 additions & 1 deletion
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

Lines changed: 251 additions & 0 deletions
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)
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