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; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy