mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
* chore: unhide multi-organization cli commands Multi-org is going into GA, unhide cli commands
417 lines
12 KiB
Go
417 lines
12 KiB
Go
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"
|
|
)
|
|
|
|
func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Command {
|
|
cmd := &serpent.Command{
|
|
Use: "roles",
|
|
Short: "Manage organization roles.",
|
|
Aliases: []string{"role"},
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
return inv.Command.HelpHandler(inv)
|
|
},
|
|
Children: []*serpent.Command{
|
|
r.showOrganizationRoles(orgContext),
|
|
r.editOrganizationRole(orgContext),
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (r *RootCmd) showOrganizationRoles(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) {
|
|
inputs, ok := data.([]codersdk.AssignableRoles)
|
|
if !ok {
|
|
return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data)
|
|
}
|
|
|
|
tableRows := make([]roleTableRow, 0)
|
|
for _, input := range inputs {
|
|
tableRows = append(tableRows, roleToTableView(input.Role))
|
|
}
|
|
|
|
return tableRows, nil
|
|
},
|
|
),
|
|
cliui.JSONFormat(),
|
|
)
|
|
|
|
client := new(codersdk.Client)
|
|
cmd := &serpent.Command{
|
|
Use: "show [role_names ...]",
|
|
Short: "Show role(s)",
|
|
Middleware: serpent.Chain(
|
|
r.InitClient(client),
|
|
),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
org, err := orgContext.Selected(inv, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
roles, err := client.ListOrganizationRoles(ctx, org.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("listing roles: %w", err)
|
|
}
|
|
|
|
if len(inv.Args) > 0 {
|
|
// filter roles
|
|
filtered := make([]codersdk.AssignableRoles, 0)
|
|
for _, role := range roles {
|
|
if slices.ContainsFunc(inv.Args, func(s string) bool {
|
|
return strings.EqualFold(s, role.Name)
|
|
}) {
|
|
filtered = append(filtered, role)
|
|
}
|
|
}
|
|
roles = filtered
|
|
}
|
|
|
|
out, err := formatter.Format(inv.Context(), roles)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = fmt.Fprintln(inv.Stdout, out)
|
|
return err
|
|
},
|
|
}
|
|
formatter.AttachOptions(&cmd.Options)
|
|
|
|
return cmd
|
|
}
|
|
|
|
func (r *RootCmd) editOrganizationRole(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: "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 := orgContext.Selected(inv, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
createNewRole := true
|
|
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")
|
|
}
|
|
|
|
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("listing existing roles: %w", err)
|
|
}
|
|
for _, existingRole := range existingRoles {
|
|
if strings.EqualFold(customRole.Name, existingRole.Name) {
|
|
// Editing an existing role
|
|
createNewRole = false
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
if len(inv.Args) == 0 {
|
|
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
|
|
}
|
|
|
|
interactiveRole, newRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
|
|
if err != nil {
|
|
return xerrors.Errorf("editing role: %w", err)
|
|
}
|
|
|
|
customRole = *interactiveRole
|
|
createNewRole = newRole
|
|
|
|
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 {
|
|
switch createNewRole {
|
|
case true:
|
|
updated, err = client.CreateOrganizationRole(ctx, customRole)
|
|
default:
|
|
updated, err = client.UpdateOrganizationRole(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
|
|
},
|
|
}
|
|
|
|
formatter.AttachOptions(&cmd.Options)
|
|
return cmd
|
|
}
|
|
|
|
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, bool, error) {
|
|
newRole := false
|
|
ctx := inv.Context()
|
|
roles, err := client.ListOrganizationRoles(ctx, orgID)
|
|
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],
|
|
OrganizationID: orgID.String(),
|
|
}
|
|
newRole = true
|
|
}
|
|
|
|
// Some checks since interactive mode is limited in what it currently sees
|
|
if len(originalRole.SitePermissions) > 0 {
|
|
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
|
|
}
|
|
|
|
if len(originalRole.UserPermissions) > 0 {
|
|
return nil, newRole, 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, newRole, xerrors.Errorf("selecting resource: %w", err)
|
|
}
|
|
switch selected {
|
|
case done:
|
|
break customRoleLoop
|
|
case abort:
|
|
return role, newRole, 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, newRole, 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, newRole, 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:"organization permissions"`
|
|
UserPermissions string `table:"user permissions"`
|
|
}
|