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 ", 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 \"") } 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[] -> Permissions OrganizationPermissions string `table:"organization permissions"` UserPermissions string `table:"user permissions"` }