feat: split cli roles edit command into create and update commands (#17121)

Closes #14239
This commit is contained in:
brettkolodny
2025-04-04 14:04:20 -04:00
committed by GitHub
parent 53af7e1b90
commit ae7afd1aa0
9 changed files with 362 additions and 78 deletions

View File

@ -26,7 +26,8 @@ func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Co
}, },
Children: []*serpent.Command{ Children: []*serpent.Command{
r.showOrganizationRoles(orgContext), r.showOrganizationRoles(orgContext),
r.editOrganizationRole(orgContext), r.updateOrganizationRole(orgContext),
r.createOrganizationRole(orgContext),
}, },
} }
return cmd return cmd
@ -99,7 +100,7 @@ func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpen
return cmd return cmd
} }
func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent.Command { func (r *RootCmd) createOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter( formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData( cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}), cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
@ -118,12 +119,12 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &serpent.Command{ cmd := &serpent.Command{
Use: "edit <role_name>", Use: "create <role_name>",
Short: "Edit an organization custom role", Short: "Create a new organization custom role",
Long: FormatExamples( Long: FormatExamples(
Example{ Example{
Description: "Run with an input.json file", Description: "Run with an input.json file",
Command: "coder roles edit --stdin < role.json", Command: "coder organization -O <organization_name> roles create --stidin < role.json",
}, },
), ),
Options: []serpent.Option{ Options: []serpent.Option{
@ -152,10 +153,13 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return err return err
} }
createNewRole := true existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
if err != nil {
return xerrors.Errorf("listing existing roles: %w", err)
}
var customRole codersdk.Role var customRole codersdk.Role
if jsonInput { if jsonInput {
// JSON Upload mode
bytes, err := io.ReadAll(inv.Stdin) bytes, err := io.ReadAll(inv.Stdin)
if err != nil { if err != nil {
return xerrors.Errorf("reading stdin: %w", err) return xerrors.Errorf("reading stdin: %w", err)
@ -175,29 +179,148 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return xerrors.Errorf("json input does not appear to be a valid role") return xerrors.Errorf("json input does not appear to be a valid role")
} }
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID) if role := existingRole(customRole.Name, existingRoles); role != nil {
if err != nil { return xerrors.Errorf("The role %s already exists. If you'd like to edit this role use the update command instead", customRole.Name)
return xerrors.Errorf("listing existing roles: %w", err)
} }
for _, existingRole := range existingRoles { } else {
if strings.EqualFold(customRole.Name, existingRole.Name) { if len(inv.Args) == 0 {
// Editing an existing role return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles create <role_name>\"")
createNewRole = false }
break
if role := existingRole(inv.Args[0], existingRoles); role != nil {
return xerrors.Errorf("The role %s already exists. If you'd like to edit this role use the update command instead", inv.Args[0])
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, nil)
if err != nil {
return xerrors.Errorf("editing role: %w", err)
}
customRole = *interactiveRole
}
var updated codersdk.Role
if dryRun {
// Do not actually post
updated = customRole
} else {
updated, err = client.CreateOrganizationRole(ctx, customRole)
if err != nil {
return xerrors.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
},
}
return cmd
}
func (r *RootCmd) updateOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization 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: "update <role_name>",
Short: "Update an organization custom role",
Long: FormatExamples(
Example{
Description: "Run with an input.json file",
Command: "coder roles update --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 := orgContext.Selected(inv, client)
if err != nil {
return err
}
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
if err != nil {
return xerrors.Errorf("listing existing roles: %w", err)
}
var customRole codersdk.Role
if jsonInput {
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("only 1 role can be sent at a time")
} }
return xerrors.Errorf("json input does not appear to be a valid role")
}
if role := existingRole(customRole.Name, existingRoles); role == nil {
return xerrors.Errorf("The role %s does not exist. If you'd like to create this role use the create command instead", customRole.Name)
} }
} else { } else {
if len(inv.Args) == 0 { if len(inv.Args) == 0 {
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"") return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
} }
interactiveRole, newRole, err := interactiveOrgRoleEdit(inv, org.ID, client) role := existingRole(inv.Args[0], existingRoles)
if role == nil {
return xerrors.Errorf("The role %s does not exist. If you'd like to create this role use the create command instead", inv.Args[0])
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, &role.Role)
if err != nil { if err != nil {
return xerrors.Errorf("editing role: %w", err) return xerrors.Errorf("editing role: %w", err)
} }
customRole = *interactiveRole customRole = *interactiveRole
createNewRole = newRole
preview := fmt.Sprintf("permissions: %d site, %d org, %d user", preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions)) len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
@ -216,12 +339,7 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
// Do not actually post // Do not actually post
updated = customRole updated = customRole
} else { } else {
switch createNewRole { updated, err = client.UpdateOrganizationRole(ctx, customRole)
case true:
updated, err = client.CreateOrganizationRole(ctx, customRole)
default:
updated, err = client.UpdateOrganizationRole(ctx, customRole)
}
if err != nil { if err != nil {
return xerrors.Errorf("patch role: %w", err) return xerrors.Errorf("patch role: %w", err)
} }
@ -241,50 +359,27 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return cmd return cmd
} }
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, bool, error) { func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, updateRole *codersdk.Role) (*codersdk.Role, error) {
newRole := false var originalRole codersdk.Role
ctx := inv.Context() if updateRole == nil {
roles, err := client.ListOrganizationRoles(ctx, orgID) originalRole = codersdk.Role{
if err != nil {
return nil, newRole, 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, newRole, xerrors.Errorf("abort: %w", err)
}
originalRole.Role = codersdk.Role{
Name: inv.Args[0], Name: inv.Args[0],
OrganizationID: orgID.String(), OrganizationID: orgID.String(),
} }
newRole = true } else {
originalRole = *updateRole
} }
// Some checks since interactive mode is limited in what it currently sees // Some checks since interactive mode is limited in what it currently sees
if len(originalRole.SitePermissions) > 0 { if len(originalRole.SitePermissions) > 0 {
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions") return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
} }
if len(originalRole.UserPermissions) > 0 { if len(originalRole.UserPermissions) > 0 {
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions") return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
} }
role := &originalRole.Role role := &originalRole
allowedResources := []codersdk.RBACResource{ allowedResources := []codersdk.RBACResource{
codersdk.ResourceTemplate, codersdk.ResourceTemplate,
codersdk.ResourceWorkspace, codersdk.ResourceWorkspace,
@ -303,13 +398,13 @@ customRoleLoop:
Options: append(permissionPreviews(role, allowedResources), done, abort), Options: append(permissionPreviews(role, allowedResources), done, abort),
}) })
if err != nil { if err != nil {
return role, newRole, xerrors.Errorf("selecting resource: %w", err) return role, xerrors.Errorf("selecting resource: %w", err)
} }
switch selected { switch selected {
case done: case done:
break customRoleLoop break customRoleLoop
case abort: case abort:
return role, newRole, xerrors.Errorf("edit role %q aborted", role.Name) return role, xerrors.Errorf("edit role %q aborted", role.Name)
default: default:
strs := strings.Split(selected, "::") strs := strings.Split(selected, "::")
resource := strings.TrimSpace(strs[0]) resource := strings.TrimSpace(strs[0])
@ -320,7 +415,7 @@ customRoleLoop:
Defaults: defaultActions(role, resource), Defaults: defaultActions(role, resource),
}) })
if err != nil { if err != nil {
return role, newRole, xerrors.Errorf("selecting actions for resource %q: %w", resource, err) return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
} }
applyOrgResourceActions(role, resource, actions) applyOrgResourceActions(role, resource, actions)
// back to resources! // back to resources!
@ -329,7 +424,7 @@ customRoleLoop:
// This println is required because the prompt ends us on the same line as some text. // This println is required because the prompt ends us on the same line as some text.
_, _ = fmt.Println() _, _ = fmt.Println()
return role, newRole, nil return role, nil
} }
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) { func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
@ -405,6 +500,16 @@ func roleToTableView(role codersdk.Role) roleTableRow {
} }
} }
func existingRole(newRoleName string, existingRoles []codersdk.AssignableRoles) *codersdk.AssignableRoles {
for _, existingRole := range existingRoles {
if strings.EqualFold(newRoleName, existingRole.Name) {
return &existingRole
}
}
return nil
}
type roleTableRow struct { type roleTableRow struct {
Name string `table:"name,default_sort"` Name string `table:"name,default_sort"`
DisplayName string `table:"display name"` DisplayName string `table:"display name"`

View File

@ -8,8 +8,9 @@ USAGE:
Aliases: role Aliases: role
SUBCOMMANDS: SUBCOMMANDS:
edit Edit an organization custom role create Create a new organization custom role
show Show role(s) show Show role(s)
update Update an organization custom role
——— ———
Run `coder --help` for a list of global options. Run `coder --help` for a list of global options.

View File

@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder organizations roles create [flags] <role_name>
Create a new organization custom role
- Run with an input.json file:
$ coder organization -O <organization_name> roles create --stidin <
role.json
OPTIONS:
--dry-run bool
Does all the work, but does not submit the final updated role.
--stdin bool
Reads stdin for the json role definition to upload.
-y, --yes bool
Bypass prompts.
———
Run `coder --help` for a list of global options.

View File

@ -1,13 +1,13 @@
coder v0.0.0-devel coder v0.0.0-devel
USAGE: USAGE:
coder organizations roles edit [flags] <role_name> coder organizations roles update [flags] <role_name>
Edit an organization custom role Update an organization custom role
- Run with an input.json file: - Run with an input.json file:
$ coder roles edit --stdin < role.json $ coder roles update --stdin < role.json
OPTIONS: OPTIONS:
-c, --column [name|display name|organization id|site permissions|organization permissions|user permissions] (default: name,display name,site permissions,organization permissions,user permissions) -c, --column [name|display name|organization id|site permissions|organization permissions|user permissions] (default: name,display name,site permissions,organization permissions,user permissions)

View File

@ -1200,15 +1200,20 @@
"path": "reference/cli/organizations_roles.md" "path": "reference/cli/organizations_roles.md"
}, },
{ {
"title": "organizations roles edit", "title": "organizations roles create",
"description": "Edit an organization custom role", "description": "Create a new organization custom role",
"path": "reference/cli/organizations_roles_edit.md" "path": "reference/cli/organizations_roles_create.md"
}, },
{ {
"title": "organizations roles show", "title": "organizations roles show",
"description": "Show role(s)", "description": "Show role(s)",
"path": "reference/cli/organizations_roles_show.md" "path": "reference/cli/organizations_roles_show.md"
}, },
{
"title": "organizations roles update",
"description": "Update an organization custom role",
"path": "reference/cli/organizations_roles_update.md"
},
{ {
"title": "organizations settings", "title": "organizations settings",
"description": "Manage organization settings.", "description": "Manage organization settings.",

View File

@ -15,7 +15,8 @@ coder organizations roles
## Subcommands ## Subcommands
| Name | Purpose | | Name | Purpose |
|----------------------------------------------------|----------------------------------| |--------------------------------------------------------|---------------------------------------|
| [<code>show</code>](./organizations_roles_show.md) | Show role(s) | | [<code>show</code>](./organizations_roles_show.md) | Show role(s) |
| [<code>edit</code>](./organizations_roles_edit.md) | Edit an organization custom role | | [<code>update</code>](./organizations_roles_update.md) | Update an organization custom role |
| [<code>create</code>](./organizations_roles_create.md) | Create a new organization custom role |

View File

@ -0,0 +1,44 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# organizations roles create
Create a new organization custom role
## Usage
```console
coder organizations roles create [flags] <role_name>
```
## Description
```console
- Run with an input.json file:
$ coder organization -O <organization_name> roles create --stidin < role.json
```
## Options
### -y, --yes
| | |
|------|-------------------|
| Type | <code>bool</code> |
Bypass prompts.
### --dry-run
| | |
|------|-------------------|
| Type | <code>bool</code> |
Does all the work, but does not submit the final updated role.
### --stdin
| | |
|------|-------------------|
| Type | <code>bool</code> |
Reads stdin for the json role definition to upload.

View File

@ -1,12 +1,12 @@
<!-- DO NOT EDIT | GENERATED CONTENT --> <!-- DO NOT EDIT | GENERATED CONTENT -->
# organizations roles edit # organizations roles update
Edit an organization custom role Update an organization custom role
## Usage ## Usage
```console ```console
coder organizations roles edit [flags] <role_name> coder organizations roles update [flags] <role_name>
``` ```
## Description ## Description
@ -14,7 +14,7 @@ coder organizations roles edit [flags] <role_name>
```console ```console
- Run with an input.json file: - Run with an input.json file:
$ coder roles edit --stdin < role.json $ coder roles update --stdin < role.json
``` ```
## Options ## Options

View File

@ -5,10 +5,13 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
@ -17,7 +20,7 @@ import (
"github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil"
) )
func TestEditOrganizationRoles(t *testing.T) { func TestCreateOrganizationRoles(t *testing.T) {
t.Parallel() t.Parallel()
// Unit test uses --stdin and json as the role input. The interactive cli would // Unit test uses --stdin and json as the role input. The interactive cli would
@ -34,7 +37,7 @@ func TestEditOrganizationRoles(t *testing.T) {
}) })
ctx := testutil.Context(t, testutil.WaitMedium) ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin") inv, root := clitest.New(t, "organization", "roles", "create", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{ inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "new-role", "name": "new-role",
"organization_id": "%s", "organization_id": "%s",
@ -72,7 +75,7 @@ func TestEditOrganizationRoles(t *testing.T) {
}) })
ctx := testutil.Context(t, testutil.WaitMedium) ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "edit", "--stdin") inv, root := clitest.New(t, "organization", "roles", "create", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{ inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "new-role", "name": "new-role",
"organization_id": "%s", "organization_id": "%s",
@ -185,3 +188,104 @@ func TestShowOrganizations(t *testing.T) {
pty.ExpectMatch(orgs["bar"].ID.String()) pty.ExpectMatch(orgs["bar"].ID.String())
}) })
} }
func TestUpdateOrganizationRoles(t *testing.T) {
t.Parallel()
t.Run("JSON", func(t *testing.T) {
t.Parallel()
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleOwner())
// Create a role in the DB with no permissions
const expectedRole = "test-role"
dbgen.CustomRole(t, db, database.CustomRole{
Name: expectedRole,
DisplayName: "Expected",
SitePermissions: nil,
OrgPermissions: nil,
UserPermissions: nil,
OrganizationID: uuid.NullUUID{
UUID: owner.OrganizationID,
Valid: true,
},
})
// Update the new role via JSON
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "update", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "test-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(), "test-role")
require.Contains(t, buf.String(), "1 permissions")
})
t.Run("InvalidRole", func(t *testing.T) {
t.Parallel()
ownerClient, _, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleOwner())
// Update the new role via JSON
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "organization", "roles", "update", "--stdin")
inv.Stdin = bytes.NewBufferString(fmt.Sprintf(`{
"name": "test-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.Error(t, err)
require.ErrorContains(t, err, "The role test-role does not exist.")
})
}