diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index 897ddec4de4d6..8080ef1a96906 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -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 { diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 3ae27ee811e50..8a56ae93797ca 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -21,7 +21,7 @@ func init() { {{- .CurrentOpt.Value}} {{- color "reset"}} {{end}} - +{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }} {{- if not .ShowAnswer }} {{- if .Config.Icons.Help.Text }} {{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }} @@ -44,18 +44,20 @@ func init() { {{- " "}}{{- .CurrentOpt.Value}} {{end}} {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} +{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }} {{- if not .ShowAnswer }} {{- "\n"}} {{- range $ix, $option := .PageEntries}} {{- template "option" $.IterateOption $ix $option}} {{- end}} -{{- end}}` +{{- end }}` } 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 +124,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 +141,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, + Message: opts.Message, + Options: opts.Options, + Default: opts.Defaults, } var values []string diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c399121adb6ec..c0da49714fc40 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -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 } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6dde991904811..8a9490a25c6a1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2149,6 +2149,32 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Upsert a custom organization role", + "operationId": "upsert-a-custom-organization-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { @@ -8345,6 +8371,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "organization_permissions": { "description": "map[\u003corg_id\u003e] -\u003e Permissions", "type": "object", @@ -11165,6 +11195,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "organization_permissions": { "description": "map[\u003corg_id\u003e] -\u003e Permissions", "type": "object", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d52e3c515d7d2..9e1cec682e68f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1876,6 +1876,28 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Upsert a custom organization role", + "operationId": "upsert-a-custom-organization-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { @@ -7410,6 +7432,10 @@ "name": { "type": "string" }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "organization_permissions": { "description": "map[\u003corg_id\u003e] -\u003e Permissions", "type": "object", @@ -10067,6 +10093,10 @@ "name": { "type": "string" }, + "organization_id": { + "type": "string", + "format": "uuid" + }, "organization_permissions": { "description": "map[\u003corg_id\u003e] -\u003e Permissions", "type": "object", diff --git a/coderd/coderd.go b/coderd/coderd.go index 80f77d92ee672..ad874d672822d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -827,6 +827,8 @@ func New(options *Options) *API { }) r.Route("/members", func(r chi.Router) { r.Get("/roles", api.assignableOrgRoles) + r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)). + Patch("/roles", api.patchOrgRoles) r.Route("/{user}", func(r chi.Router) { r.Use( httpmw.ExtractOrganizationMemberParam(options.Database), @@ -1247,6 +1249,8 @@ type API struct { // passed to dbauthz. AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] PortSharer atomic.Pointer[portsharing.PortSharer] + // CustomRoleHandler is the AGPL/Enterprise implementation for custom roles. + CustomRoleHandler atomic.Pointer[CustomRoleHandler] HTTPAuth *HTTPAuthorizer diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index ab6f3aa82b3f6..590183bd43dd1 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -527,12 +527,17 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner } func Role(role rbac.Role) codersdk.Role { + roleName, orgIDStr, err := rbac.RoleSplit(role.Name) + if err != nil { + roleName = role.Name + } return codersdk.Role{ - Name: role.Name, + Name: roleName, + OrganizationID: orgIDStr, DisplayName: role.DisplayName, SitePermissions: List(role.Site, Permission), OrganizationPermissions: Map(role.Org, ListLazy(Permission)), - UserPermissions: List(role.Site, Permission), + UserPermissions: List(role.User, Permission), } } @@ -546,7 +551,7 @@ func Permission(permission rbac.Permission) codersdk.Permission { func RoleToRBAC(role codersdk.Role) rbac.Role { return rbac.Role{ - Name: role.Name, + Name: rbac.RoleName(role.Name, role.OrganizationID), DisplayName: role.DisplayName, Site: List(role.SitePermissions, PermissionToRBAC), Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)), diff --git a/coderd/database/models.go b/coderd/database/models.go index 42c41c83bd5dc..e8934fc12678e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8c75b9dcb53a9..8d58ab593c17c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c38de30b4cb84..0c773ecb67dee 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -5571,15 +5571,21 @@ WHERE organization_id IS null ELSE true END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = $3 + ELSE true + END ` type CustomRolesParams struct { - LookupRoles []string `db:"lookup_roles" json:"lookup_roles"` - ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` + LookupRoles []string `db:"lookup_roles" json:"lookup_roles"` + ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) { - rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles) + rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles, arg.OrganizationID) if err != nil { return nil, err } @@ -5615,6 +5621,7 @@ INSERT INTO custom_roles ( name, display_name, + organization_id, site_permissions, org_permissions, user_permissions, @@ -5628,15 +5635,16 @@ VALUES ( $3, $4, $5, + $6, now(), now() ) ON CONFLICT (name) DO UPDATE SET display_name = $2, - site_permissions = $3, - org_permissions = $4, - user_permissions = $5, + site_permissions = $4, + org_permissions = $5, + user_permissions = $6, updated_at = now() RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id ` @@ -5644,6 +5652,7 @@ RETURNING name, display_name, site_permissions, org_permissions, user_permission type UpsertCustomRoleParams struct { Name string `db:"name" json:"name"` DisplayName string `db:"display_name" json:"display_name"` + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` @@ -5653,6 +5662,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP row := q.db.QueryRowContext(ctx, upsertCustomRole, arg.Name, arg.DisplayName, + arg.OrganizationID, arg.SitePermissions, arg.OrgPermissions, arg.UserPermissions, diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index 2137dea34b077..df14c385003eb 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -16,6 +16,11 @@ WHERE organization_id IS null ELSE true END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = @organization_id + ELSE true + END ; -- name: UpsertCustomRole :one @@ -23,6 +28,7 @@ INSERT INTO custom_roles ( name, display_name, + organization_id, site_permissions, org_permissions, user_permissions, @@ -33,6 +39,7 @@ VALUES ( -- Always force lowercase names lower(@name), @display_name, + @organization_id, @site_permissions, @org_permissions, @user_permissions, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 7086e2fe0e2a4..137d2c0c1258b 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -53,29 +53,29 @@ func (names RoleNames) Names() []string { // site and orgs, and these functions can be removed. func RoleOwner() string { - return roleName(owner, "") + return RoleName(owner, "") } -func CustomSiteRole() string { return roleName(customSiteRole, "") } +func CustomSiteRole() string { return RoleName(customSiteRole, "") } func RoleTemplateAdmin() string { - return roleName(templateAdmin, "") + return RoleName(templateAdmin, "") } func RoleUserAdmin() string { - return roleName(userAdmin, "") + return RoleName(userAdmin, "") } func RoleMember() string { - return roleName(member, "") + return RoleName(member, "") } func RoleOrgAdmin(organizationID uuid.UUID) string { - return roleName(orgAdmin, organizationID.String()) + return RoleName(orgAdmin, organizationID.String()) } func RoleOrgMember(organizationID uuid.UUID) string { - return roleName(orgMember, organizationID.String()) + return RoleName(orgMember, organizationID.String()) } func allPermsExcept(excepts ...Objecter) []Permission { @@ -273,7 +273,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // organization scope. orgAdmin: func(organizationID string) Role { return Role{ - Name: roleName(orgAdmin, organizationID), + Name: RoleName(orgAdmin, organizationID), DisplayName: "Organization Admin", Site: []Permission{}, Org: map[string][]Permission{ @@ -291,7 +291,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // in an organization. orgMember: func(organizationID string) Role { return Role{ - Name: roleName(orgMember, organizationID), + Name: RoleName(orgMember, organizationID), DisplayName: "", Site: []Permission{}, Org: map[string][]Permission{ @@ -475,13 +475,13 @@ func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool { // For CanAssignRole, we only care about the names of the roles. roles := expandable.Names() - assigned, assignedOrg, err := roleSplit(assignedRole) + assigned, assignedOrg, err := RoleSplit(assignedRole) if err != nil { return false } for _, longRole := range roles { - role, orgID, err := roleSplit(longRole) + role, orgID, err := RoleSplit(longRole) if err != nil { continue } @@ -510,7 +510,7 @@ func CanAssignRole(expandable ExpandableRoles, assignedRole string) bool { // api. We should maybe make an exported function that returns just the // human-readable content of the Role struct (name + display name). func RoleByName(name string) (Role, error) { - roleName, orgID, err := roleSplit(name) + roleName, orgID, err := RoleSplit(name) if err != nil { return Role{}, xerrors.Errorf("parse role name: %w", err) } @@ -544,7 +544,7 @@ func rolesByNames(roleNames []string) ([]Role, error) { } func IsOrgRole(roleName string) (string, bool) { - _, orgID, err := roleSplit(roleName) + _, orgID, err := RoleSplit(roleName) if err == nil && orgID != "" { return orgID, true } @@ -561,7 +561,7 @@ func OrganizationRoles(organizationID uuid.UUID) []Role { var roles []Role for _, roleF := range builtInRoles { role := roleF(organizationID.String()) - _, scope, err := roleSplit(role.Name) + _, scope, err := RoleSplit(role.Name) if err != nil { // This should never happen continue @@ -582,7 +582,7 @@ func SiteRoles() []Role { var roles []Role for _, roleF := range builtInRoles { role := roleF("random") - _, scope, err := roleSplit(role.Name) + _, scope, err := RoleSplit(role.Name) if err != nil { // This should never happen continue @@ -625,19 +625,19 @@ func ChangeRoleSet(from []string, to []string) (added []string, removed []string return added, removed } -// roleName is a quick helper function to return +// RoleName is a quick helper function to return // // role_name:scopeID // // If no scopeID is required, only 'role_name' is returned -func roleName(name string, orgID string) string { +func RoleName(name string, orgID string) string { if orgID == "" { return name } return name + ":" + orgID } -func roleSplit(role string) (name string, orgID string, err error) { +func RoleSplit(role string) (name string, orgID string, err error) { arr := strings.Split(role, ":") if len(arr) > 2 { return "", "", xerrors.Errorf("too many colons in role name") diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 9881cde028826..b1957fb3b25e9 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" @@ -95,8 +96,12 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, } func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { + name := dbRole.Name + if dbRole.OrganizationID.Valid { + name = rbac.RoleName(dbRole.Name, dbRole.OrganizationID.UUID.String()) + } role := rbac.Role{ - Name: dbRole.Name, + Name: name, DisplayName: dbRole.DisplayName, Site: nil, Org: nil, @@ -122,11 +127,27 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { } func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) { + roleName, orgIDStr, err := rbac.RoleSplit(role.Name) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("split role %q: %w", role.Name, err) + } + dbRole := database.CustomRole{ - Name: role.Name, + Name: roleName, DisplayName: role.DisplayName, } + if orgIDStr != "" { + orgID, err := uuid.Parse(orgIDStr) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("parse org id %q: %w", orgIDStr, err) + } + dbRole.OrganizationID = uuid.NullUUID{ + UUID: orgID, + Valid: true, + } + } + siteData, err := json.Marshal(role.Site) if err != nil { return dbRole, xerrors.Errorf("marshal site permissions: %w", err) diff --git a/coderd/roles.go b/coderd/roles.go index 3d6245f9d4594..d65d708d849f7 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -1,8 +1,11 @@ package coderd import ( + "context" "net/http" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpmw" @@ -14,6 +17,59 @@ import ( "github.com/coder/coder/v2/coderd/rbac" ) +// CustomRoleHandler handles AGPL/Enterprise interface for handling custom +// roles. Ideally only included in the enterprise package, but the routes are +// intermixed. +type CustomRoleHandler interface { + PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) +} + +type agplCustomRoleHandler struct{} + +func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Creating and updating custom roles is an Enterprise feature. Contact sales!", + }) + return codersdk.Role{}, false +} + +// patchRole will allow creating a custom organization role +// +// @Summary Upsert a custom organization role +// @ID upsert-a-custom-organization-role +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Success 200 {array} codersdk.Role +// @Router /organizations/{organization}/members/roles [patch] +func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + handler = *api.CustomRoleHandler.Load() + organization = httpmw.OrganizationParam(r) + ) + + var req codersdk.Role + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if err := httpapi.NameValid(req.Name); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid role name", + Detail: err.Error(), + }) + return + } + + updated, ok := handler.PatchOrganizationRole(ctx, api.Database, rw, organization.ID, req) + if !ok { + return + } + + httpapi.Write(ctx, rw, http.StatusOK, updated) +} + // AssignableSiteRoles returns all site wide roles that can be assigned. // // @Summary Get site member roles @@ -73,6 +129,23 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.OrganizationRoles(organization.ID) + dbCustomRoles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: nil, + ExcludeOrgRoles: false, + OrganizationID: organization.ID, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + customRoles := make([]rbac.Role, 0, len(dbCustomRoles)) + for _, customRole := range dbCustomRoles { + rbacRole, err := rolestore.ConvertDBRole(customRole) + if err == nil { + customRoles = append(customRoles, rbacRole) + } + } httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles, []rbac.Role{})) } diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index f06930f373557..9bb1da930ff45 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -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)) diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 9c7d9cc485128..42db5449c29f4 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -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}, +} diff --git a/codersdk/roles.go b/codersdk/roles.go index 29b0174931fbe..c803e92f44bb2 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -35,7 +35,8 @@ type Permission struct { // Role is a longer form of SlimRole used to edit custom roles. type Role struct { - Name string `json:"name" table:"name,default_sort"` + Name string `json:"name" table:"name,default_sort" validate:"username"` + OrganizationID string `json:"organization_id" table:"organization_id" format:"uuid"` DisplayName string `json:"display_name" table:"display_name"` SitePermissions []Permission `json:"site_permissions" table:"site_permissions"` // map[] -> Permissions diff --git a/docs/api/members.md b/docs/api/members.md index 8b34200e50e95..4e1b047083bef 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -30,6 +30,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "built_in": true, "display_name": "string", "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "organization_permissions": { "property1": [ { @@ -81,6 +82,133 @@ Status Code **200** | `» built_in` | boolean | false | | Built in roles are immutable | | `» display_name` | string | false | | | | `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert a custom organization role + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/members/roles \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /organizations/{organization}/members/roles` + +### Example responses + +> 200 Response + +```json +[ + { + "display_name": "string", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Role](schemas.md#codersdkrole) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | | `» organization_permissions` | object | false | | map[] -> Permissions | | `»» [any property]` | array | false | | | | `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | @@ -215,6 +343,7 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ "built_in": true, "display_name": "string", "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "organization_permissions": { "property1": [ { @@ -266,6 +395,7 @@ Status Code **200** | `» built_in` | boolean | false | | Built in roles are immutable | | `» display_name` | string | false | | | | `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | | `» organization_permissions` | object | false | | map[] -> Permissions | | `»» [any property]` | array | false | | | | `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | @@ -341,6 +471,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/roles \ { "display_name": "string", "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "organization_permissions": { "property1": [ { @@ -390,6 +521,7 @@ Status Code **200** | `[array item]` | array | false | | | | `» display_name` | string | false | | | | `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | | `» organization_permissions` | object | false | | map[] -> Permissions | | `»» [any property]` | array | false | | | | `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d1b6c6a3d82e0..5b8fe57f39da9 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -805,6 +805,7 @@ "built_in": true, "display_name": "string", "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "organization_permissions": { "property1": [ { @@ -846,6 +847,7 @@ | `built_in` | boolean | false | | Built in roles are immutable | | `display_name` | string | false | | | | `name` | string | false | | | +| `organization_id` | string | false | | | | `organization_permissions` | object | false | | map[] -> Permissions | | » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | | `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | @@ -4327,6 +4329,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "display_name": "string", "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "organization_permissions": { "property1": [ { @@ -4366,6 +4369,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | | `display_name` | string | false | | | | `name` | string | false | | | +| `organization_id` | string | false | | | | `organization_permissions` | object | false | | map[] -> Permissions | | » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | | `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | diff --git a/enterprise/cli/roleedit.go b/enterprise/cli/roleedit.go new file mode 100644 index 0000000000000..ee05cf956d853 --- /dev/null +++ b/enterprise/cli/roleedit.go @@ -0,0 +1,270 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli" + "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) editRole() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]roleTableView{}, []string{"name", "display_name", "site_permissions", "org_permissions", "user_permissions"}), + func(data any) (any, error) { + typed, _ := data.(codersdk.Role) + return []roleTableView{roleToTableView(typed)}, nil + }, + ), + cliui.JSONFormat(), + ) + + var ( + dryRun bool + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "edit ", + Short: "Edit a custom role", + Long: cli.FormatExamples( + cli.Example{ + Description: "Run with an input.json file", + Command: "coder roles edit custom_name < 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), + }, + }, + Middleware: serpent.Chain( + serpent.RequireRangeArgs(0, 1), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + + var customRole codersdk.Role + fi, _ := os.Stdin.Stat() + if (fi.Mode() & os.ModeCharDevice) == 0 { + // JSON Upload mode + bytes, err := io.ReadAll(os.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 { + interactiveRole, err := interactiveEdit(inv, client) + if err != nil { + return xerrors.Errorf("editing role: %w", err) + } + + customRole = *interactiveRole + + // Only the interactive can answer prompts. + totalOrg := 0 + for _, o := range customRole.OrganizationPermissions { + totalOrg += len(o) + } + preview := fmt.Sprintf("perms: %d site, %d over %d orgs, %d user", + len(customRole.SitePermissions), totalOrg, 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 err error + var updated codersdk.Role + if dryRun { + // Do not actually post + updated = customRole + } else { + updated, err = client.PatchRole(ctx, 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 interactiveEdit(inv *serpent.Invocation, client *codersdk.Client) (*codersdk.Role, error) { + ctx := inv.Context() + roles, err := client.ListSiteRoles(ctx) + 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 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], + } + } + + // Some checks since interactive mode is limited in what it currently sees + if len(originalRole.OrganizationPermissions) > 0 { + return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains organization 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) + } + applyResourceActions(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 applyResourceActions(role *codersdk.Role, resource string, actions []string) { + // Construct new site perms with only new perms for the resource + keep := make([]codersdk.Permission, 0) + for _, perm := range role.SitePermissions { + 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.SitePermissions = keep +} + +func defaultActions(role *codersdk.Role, resource string) []string { + defaults := make([]string, 0) + for _, perm := range role.SitePermissions { + 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 { + count := 0 + for _, perm := range role.SitePermissions { + if perm.ResourceType == resource { + count++ + } + } + return fmt.Sprintf("%s :: %d permissions", resource, count) +} diff --git a/enterprise/cli/rolescmd.go b/enterprise/cli/rolescmd.go index b0a9346697a01..eec181bbe61a9 100644 --- a/enterprise/cli/rolescmd.go +++ b/enterprise/cli/rolescmd.go @@ -26,6 +26,7 @@ func (r *RootCmd) roles() *serpent.Command { Hidden: true, Children: []*serpent.Command{ r.showRole(), + r.editRole(), }, } return cmd @@ -43,13 +44,9 @@ func (r *RootCmd) showRole() *serpent.Command { 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, + roleTableView: roleToTableView(role.Role), + Assignable: role.Assignable, + BuiltIn: role.BuiltIn, }) } return rows, nil @@ -99,13 +96,27 @@ func (r *RootCmd) showRole() *serpent.Command { return cmd } -type assignableRolesTableRow struct { +func roleToTableView(role codersdk.Role) roleTableView { + return roleTableView{ + 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)), + } +} + +type roleTableView struct { Name string `table:"name,default_sort"` DisplayName string `table:"display_name"` SitePermissions string ` table:"site_permissions"` // map[] -> Permissions OrganizationPermissions string `table:"org_permissions"` UserPermissions string `table:"user_permissions"` - Assignable bool `table:"assignable"` - BuiltIn bool `table:"built_in"` +} + +type assignableRolesTableRow struct { + roleTableView `table:"r,recursive_inline"` + Assignable bool `table:"assignable"` + BuiltIn bool `table:"built_in"` } diff --git a/enterprise/cmd/coder/role.json b/enterprise/cmd/coder/role.json new file mode 100644 index 0000000000000..648310c2c890e --- /dev/null +++ b/enterprise/cmd/coder/role.json @@ -0,0 +1,2 @@ + { + } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 524bfd26f3d74..144a6edc3f66a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -761,6 +761,11 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.PortSharer.Store(&ps) } + if initial, changed, enabled := featureChanged(codersdk.FeatureCustomRoles); shouldUpdate(initial, changed, enabled) { + var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{Enabled: enabled} + api.AGPL.CustomRoleHandler.Store(&handler) + } + // External token encryption is soft-enforced featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption] featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0 diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 552197f7c4401..8925031f0de44 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -1,8 +1,12 @@ package coderd import ( + "context" + "fmt" "net/http" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" @@ -10,6 +14,100 @@ import ( "github.com/coder/coder/v2/codersdk" ) +type enterpriseCustomRoleHandler struct { + Enabled bool +} + +func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) { + if !h.Enabled { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is not enabled", + }) + return codersdk.Role{}, false + } + + // Only organization permissions are allowed to be granted + if len(role.SitePermissions) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, not allowed to assign site wide permissions for an organization role.", + Detail: "organization scoped roles may not contain site wide permissions", + }) + return codersdk.Role{}, false + } + + if len(role.UserPermissions) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, not allowed to assign user permissions for an organization role.", + Detail: "organization scoped roles may not contain user permissions", + }) + return codersdk.Role{}, false + } + + if len(role.OrganizationPermissions) > 1 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, Only 1 organization can be assigned permissions", + Detail: "roles can only contain 1 organization", + }) + return codersdk.Role{}, false + } + + if len(role.OrganizationPermissions) == 1 { + _, exists := role.OrganizationPermissions[orgID.String()] + if !exists { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid request, expected permissions for only the orgnization %q", orgID.String()), + Detail: fmt.Sprintf("only org id %s allowed", orgID.String()), + }) + return codersdk.Role{}, false + } + } + + // Make sure all permissions inputted are valid according to our policy. + rbacRole := db2sdk.RoleToRBAC(role) + args, err := rolestore.ConvertRoleToDB(rbacRole) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Detail: err.Error(), + }) + return codersdk.Role{}, false + } + + inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: args.Name, + DisplayName: args.DisplayName, + OrganizationID: uuid.NullUUID{ + UUID: orgID, + Valid: true, + }, + SitePermissions: args.SitePermissions, + OrgPermissions: args.OrgPermissions, + UserPermissions: args.UserPermissions, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return codersdk.Role{}, false + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update role permissions", + Detail: err.Error(), + }) + return codersdk.Role{}, false + } + + convertedInsert, err := rolestore.ConvertDBRole(inserted) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Permissions were updated, unable to read them back out of the database.", + Detail: err.Error(), + }) + return codersdk.Role{}, false + } + + return db2sdk.Role(convertedInsert), true +} + // patchRole will allow creating a custom role // // @Summary Upsert a custom site-wide role diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 67b863e63bacd..6cf55e29b4047 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -67,9 +67,18 @@ func TestCustomRole(t *testing.T) { allRoles, err := tmplAdmin.ListSiteRoles(ctx) require.NoError(t, err) + var foundRole codersdk.AssignableRoles require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { - return selected.Name == role.Name + if selected.Name == role.Name { + foundRole = selected + return true + } + return false }), "role missing from site role list") + + require.Len(t, foundRole.SitePermissions, 7) + require.Len(t, foundRole.OrganizationPermissions, 0) + require.Len(t, foundRole.UserPermissions, 0) }) // Revoked licenses cannot modify/create custom roles, but they can @@ -198,6 +207,6 @@ func TestCustomRole(t *testing.T) { OrganizationPermissions: nil, UserPermissions: nil, }) - require.ErrorContains(t, err, "Invalid role name") + require.ErrorContains(t, err, "Validation") }) } diff --git a/scripts/rbacgen/codersdk.gotmpl b/scripts/rbacgen/codersdk.gotmpl index 1492eaf86c2bf..dff4e165b1df5 100644 --- a/scripts/rbacgen/codersdk.gotmpl +++ b/scripts/rbacgen/codersdk.gotmpl @@ -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 }} +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a809b10220993..cd71358dbbfe7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -977,6 +977,7 @@ export interface Response { // From codersdk/roles.go export interface Role { readonly name: string; + readonly organization_id: string; readonly display_name: string; readonly site_permissions: readonly Permission[]; readonly organization_permissions: Record;