chore: add edit organization role to cli (#13365)

Editing custom org roles from hidden org cli command.
This commit is contained in:
Steven Masley
2024-06-03 09:34:10 -05:00
committed by GitHub
parent 24ba81930b
commit 973cc2b875
8 changed files with 484 additions and 23 deletions
+4 -1
View File
@@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
return "", err
}
values, err := MultiSelect(inv, options)
values, err := MultiSelect(inv, MultiSelectOptions{
Options: options,
Defaults: options,
})
if err == nil {
v, err := json.Marshal(&values)
if err != nil {
+13 -4
View File
@@ -56,6 +56,7 @@ type SelectOptions struct {
Options []string
// Default will be highlighted first if it's a valid option.
Default string
Message string
Size int
HideSearch bool
}
@@ -122,6 +123,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
Options: opts.Options,
Default: defaultOption,
PageSize: opts.Size,
Message: opts.Message,
}, &value, survey.WithIcons(func(is *survey.IconSet) {
is.Help.Text = "Type to search"
if opts.HideSearch {
@@ -138,15 +140,22 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
return value, err
}
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
type MultiSelectOptions struct {
Message string
Options []string
Defaults []string
}
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
// Similar hack is applied to Select()
if flag.Lookup("test.v") != nil {
return items, nil
return opts.Defaults, nil
}
prompt := &survey.MultiSelect{
Options: items,
Default: items,
Options: opts.Options,
Default: opts.Defaults,
Message: opts.Message,
}
var values []string
+4 -1
View File
@@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
var values []string
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
selectedItems, err := cliui.MultiSelect(inv, items)
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Options: items,
Defaults: items,
})
if err == nil {
values = selectedItems
}
+300 -17
View File
@@ -1,13 +1,17 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"slices"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
@@ -23,6 +27,7 @@ func (r *RootCmd) organizationRoles() *serpent.Command {
Hidden: true,
Children: []*serpent.Command{
r.showOrganizationRoles(),
r.editOrganizationRole(),
},
}
return cmd
@@ -31,25 +36,19 @@ func (r *RootCmd) organizationRoles() *serpent.Command {
func (r *RootCmd) showOrganizationRoles() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}),
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}),
func(data any) (any, error) {
input, ok := data.([]codersdk.AssignableRoles)
inputs, ok := data.([]codersdk.AssignableRoles)
if !ok {
return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data)
}
rows := make([]assignableRolesTableRow, 0, len(input))
for _, role := range input {
rows = append(rows, assignableRolesTableRow{
Name: role.Name,
DisplayName: role.DisplayName,
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)),
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
Assignable: role.Assignable,
BuiltIn: role.BuiltIn,
})
tableRows := make([]roleTableRow, 0)
for _, input := range inputs {
tableRows = append(tableRows, roleToTableView(input.Role))
}
return rows, nil
return tableRows, nil
},
),
cliui.JSONFormat(),
@@ -101,13 +100,297 @@ func (r *RootCmd) showOrganizationRoles() *serpent.Command {
return cmd
}
type assignableRolesTableRow struct {
func (r *RootCmd) editOrganizationRole() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}),
func(data any) (any, error) {
typed, _ := data.(codersdk.Role)
return []roleTableRow{roleToTableView(typed)}, nil
},
),
cliui.JSONFormat(),
)
var (
dryRun bool
jsonInput bool
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "edit <role_name>",
Short: "Edit an organization custom role",
Long: FormatExamples(
Example{
Description: "Run with an input.json file",
Command: "coder roles edit --stdin < role.json",
},
),
Options: []serpent.Option{
cliui.SkipPromptOption(),
{
Name: "dry-run",
Description: "Does all the work, but does not submit the final updated role.",
Flag: "dry-run",
Value: serpent.BoolOf(&dryRun),
},
{
Name: "stdin",
Description: "Reads stdin for the json role definition to upload.",
Flag: "stdin",
Value: serpent.BoolOf(&jsonInput),
},
},
Middleware: serpent.Chain(
serpent.RequireRangeArgs(0, 1),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
org, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
var customRole codersdk.Role
if jsonInput {
// JSON Upload mode
bytes, err := io.ReadAll(inv.Stdin)
if err != nil {
return xerrors.Errorf("reading stdin: %w", err)
}
err = json.Unmarshal(bytes, &customRole)
if err != nil {
return xerrors.Errorf("parsing stdin json: %w", err)
}
if customRole.Name == "" {
arr := make([]json.RawMessage, 0)
err = json.Unmarshal(bytes, &arr)
if err == nil && len(arr) > 0 {
return xerrors.Errorf("the input appears to be an array, only 1 role can be sent at a time")
}
return xerrors.Errorf("json input does not appear to be a valid role")
}
} else {
if len(inv.Args) == 0 {
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
if err != nil {
return xerrors.Errorf("editing role: %w", err)
}
customRole = *interactiveRole
preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Are you sure you wish to update the role? " + preview,
Default: "yes",
IsConfirm: true,
})
if err != nil {
return xerrors.Errorf("abort: %w", err)
}
}
var updated codersdk.Role
if dryRun {
// Do not actually post
updated = customRole
} else {
updated, err = client.PatchOrganizationRole(ctx, org.ID, customRole)
if err != nil {
return fmt.Errorf("patch role: %w", err)
}
}
output, err := formatter.Format(ctx, updated)
if err != nil {
return xerrors.Errorf("formatting: %w", err)
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, error) {
ctx := inv.Context()
roles, err := client.ListOrganizationRoles(ctx, orgID)
if err != nil {
return nil, xerrors.Errorf("listing roles: %w", err)
}
// Make sure the role actually exists first
var originalRole codersdk.AssignableRoles
for _, r := range roles {
if strings.EqualFold(inv.Args[0], r.Name) {
originalRole = r
break
}
}
if originalRole.Name == "" {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "No organization role exists with that name, do you want to create one?",
Default: "yes",
IsConfirm: true,
})
if err != nil {
return nil, xerrors.Errorf("abort: %w", err)
}
originalRole.Role = codersdk.Role{
Name: inv.Args[0],
OrganizationID: orgID.String(),
}
}
// Some checks since interactive mode is limited in what it currently sees
if len(originalRole.SitePermissions) > 0 {
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
}
if len(originalRole.UserPermissions) > 0 {
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
}
role := &originalRole.Role
allowedResources := []codersdk.RBACResource{
codersdk.ResourceTemplate,
codersdk.ResourceWorkspace,
codersdk.ResourceUser,
codersdk.ResourceGroup,
}
const done = "Finish and submit changes"
const abort = "Cancel changes"
// Now starts the role editing "game".
customRoleLoop:
for {
selected, err := cliui.Select(inv, cliui.SelectOptions{
Message: "Select which resources to edit permissions",
Options: append(permissionPreviews(role, allowedResources), done, abort),
})
if err != nil {
return role, xerrors.Errorf("selecting resource: %w", err)
}
switch selected {
case done:
break customRoleLoop
case abort:
return role, xerrors.Errorf("edit role %q aborted", role.Name)
default:
strs := strings.Split(selected, "::")
resource := strings.TrimSpace(strs[0])
actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource),
Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]),
Defaults: defaultActions(role, resource),
})
if err != nil {
return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
}
applyOrgResourceActions(role, resource, actions)
// back to resources!
}
}
// This println is required because the prompt ends us on the same line as some text.
_, _ = fmt.Println()
return role, nil
}
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = make([]codersdk.Permission, 0)
}
// Construct new site perms with only new perms for the resource
keep := make([]codersdk.Permission, 0)
for _, perm := range role.OrganizationPermissions {
perm := perm
if string(perm.ResourceType) != resource {
keep = append(keep, perm)
}
}
// Add new perms
for _, action := range actions {
keep = append(keep, codersdk.Permission{
Negate: false,
ResourceType: codersdk.RBACResource(resource),
Action: codersdk.RBACAction(action),
})
}
role.OrganizationPermissions = keep
}
func defaultActions(role *codersdk.Role, resource string) []string {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = []codersdk.Permission{}
}
defaults := make([]string, 0)
for _, perm := range role.OrganizationPermissions {
if string(perm.ResourceType) == resource {
defaults = append(defaults, string(perm.Action))
}
}
return defaults
}
func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string {
previews := make([]string, 0, len(resources))
for _, resource := range resources {
previews = append(previews, permissionPreview(role, resource))
}
return previews
}
func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string {
if role.OrganizationPermissions == nil {
role.OrganizationPermissions = []codersdk.Permission{}
}
count := 0
for _, perm := range role.OrganizationPermissions {
if perm.ResourceType == resource {
count++
}
}
return fmt.Sprintf("%s :: %d permissions", resource, count)
}
func roleToTableView(role codersdk.Role) roleTableRow {
return roleTableRow{
Name: role.Name,
DisplayName: role.DisplayName,
OrganizationID: role.OrganizationID,
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
OrganizationPermissions: fmt.Sprintf("%d permissions", len(role.OrganizationPermissions)),
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
}
}
type roleTableRow struct {
Name string `table:"name,default_sort"`
DisplayName string `table:"display_name"`
OrganizationID string `table:"organization_id"`
SitePermissions string ` table:"site_permissions"`
// map[<org_id>] -> Permissions
OrganizationPermissions string `table:"org_permissions"`
UserPermissions string `table:"user_permissions"`
Assignable bool `table:"assignable"`
BuiltIn bool `table:"built_in"`
}
+9
View File
@@ -4,6 +4,15 @@ import (
"golang.org/x/exp/constraints"
)
// ToStrings works for any type where the base type is a string.
func ToStrings[T ~string](a []T) []string {
tmp := make([]string, 0, len(a))
for _, v := range a {
tmp = append(tmp, string(v))
}
return tmp
}
// Omit creates a new slice with the arguments omitted from the list.
func Omit[T comparable](a []T, omits ...T) []T {
tmp := make([]T, 0, len(a))
+30
View File
@@ -48,3 +48,33 @@ const (
ActionWorkspaceStart RBACAction = "start"
ActionWorkspaceStop RBACAction = "stop"
)
// RBACResourceActions is the mapping of resources to which actions are valid for
// said resource type.
var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceWildcard: []RBACAction{},
ResourceApiKey: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceAssignOrgRole: []RBACAction{ActionAssign, ActionDelete, ActionRead},
ResourceAssignRole: []RBACAction{ActionAssign, ActionCreate, ActionDelete, ActionRead},
ResourceAuditLog: []RBACAction{ActionCreate, ActionRead},
ResourceDebugInfo: []RBACAction{ActionRead},
ResourceDeploymentConfig: []RBACAction{ActionRead, ActionUpdate},
ResourceDeploymentStats: []RBACAction{ActionRead},
ResourceFile: []RBACAction{ActionCreate, ActionRead},
ResourceGroup: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceLicense: []RBACAction{ActionCreate, ActionDelete, ActionRead},
ResourceOauth2App: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOauth2AppCodeToken: []RBACAction{ActionCreate, ActionDelete, ActionRead},
ResourceOauth2AppSecret: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganization: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganizationMember: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceProvisionerDaemon: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceReplicas: []RBACAction{ActionRead},
ResourceSystem: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceTailnetCoordinator: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceTemplate: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights},
ResourceUser: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal},
ResourceWorkspace: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
ResourceWorkspaceDormant: []RBACAction{ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate},
ResourceWorkspaceProxy: []RBACAction{ActionCreate, ActionDelete, ActionRead, ActionUpdate},
}
+112
View File
@@ -0,0 +1,112 @@
package cli_test
import (
"bytes"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"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 TestEditOrganizationRoles(t *testing.T) {
t.Parallel()
// Unit test uses --stdin and json as the role input. The interactive cli would
// be hard to drive from a unit test.
t.Run("JSON", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "new-role",
"organization_id": "%s",
"display_name": "",
"site_permissions": [],
"organization_permissions": [
{
"resource_type": "workspace",
"action": "read"
}
],
"user_permissions": [],
"assignable": false,
"built_in": false
}`, owner.OrganizationID.String()))
//nolint:gocritic // only owners can edit roles
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
require.Contains(t, buf.String(), "new-role")
})
t.Run("InvalidRole", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "new-role",
"organization_id": "%s",
"display_name": "",
"site_permissions": [
{
"resource_type": "workspace",
"action": "read"
}
],
"organization_permissions": [
{
"resource_type": "workspace",
"action": "read"
}
],
"user_permissions": [],
"assignable": false,
"built_in": false
}`, owner.OrganizationID.String()))
//nolint:gocritic // only owners can edit roles
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
inv.Stdout = buf
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "not allowed to assign site wide permissions for an organization role")
})
}
+12
View File
@@ -16,3 +16,15 @@ const (
{{ $element.Enum }} RBACAction = "{{ $element.Value }}"
{{- end }}
)
// RBACResourceActions is the mapping of resources to which actions are valid for
// said resource type.
var RBACResourceActions = map[RBACResource][]RBACAction{
{{- range $element := . }}
Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{
{{- range $actionValue, $_ := $element.Actions }}
{{- actionEnum $actionValue -}},
{{- end -}}
},
{{- end }}
}