From c56c5b3cc52347b6651c39854dbfc85133c41c96 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 10:09:13 -0500 Subject: [PATCH 1/4] chore: implement api for creating custom roles --- coderd/apidoc/docs.go | 87 +++++++++- coderd/apidoc/swagger.json | 87 +++++++++- coderd/audit.go | 4 +- coderd/coderd.go | 2 + coderd/database/db2sdk/db2sdk.go | 77 ++++++++- coderd/database/dbauthz/dbauthz.go | 3 +- coderd/httpmw/apikey.go | 13 +- coderd/members.go | 6 +- coderd/rbac/rolestore/rolestore.go | 109 ++++++++++++ coderd/roles.go | 2 +- coderd/roles_test.go | 6 +- coderd/users.go | 23 +-- coderd/users_test.go | 2 +- codersdk/deployment.go | 4 + codersdk/organizations.go | 10 +- codersdk/roles.go | 54 +++++- codersdk/users.go | 2 +- docs/api/members.md | 124 ++++++++++++++ docs/api/schemas.md | 155 +++++++++++++----- enterprise/coderd/coderd.go | 10 ++ enterprise/coderd/roles.go | 80 +++++++++ enterprise/coderd/roles_test.go | 63 +++++++ enterprise/coderd/userauth.go | 3 +- enterprise/coderd/users.go | 25 +++ site/src/api/api.ts | 2 +- site/src/api/typesGenerated.ts | 26 ++- site/src/pages/UsersPage/UsersPage.test.tsx | 4 +- site/src/pages/UsersPage/UsersPageView.tsx | 2 +- .../UsersPage/UsersTable/EditRolesButton.tsx | 6 +- .../UsersPage/UsersTable/UserRoleCell.tsx | 14 +- .../pages/UsersPage/UsersTable/UsersTable.tsx | 2 +- .../UsersPage/UsersTable/UsersTableBody.tsx | 2 +- site/src/testHelpers/entities.ts | 12 +- 33 files changed, 890 insertions(+), 131 deletions(-) create mode 100644 enterprise/coderd/roles.go create mode 100644 enterprise/coderd/roles_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8e7fad2c05a49..5883fdb2f47c8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4286,6 +4286,32 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/users/{user}": { @@ -9547,17 +9573,20 @@ const docTemplate = `{ "enum": [ "example", "auto-fill-parameters", - "multi-organization" + "multi-organization", + "custom-roles" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { @@ -10372,7 +10401,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -10452,6 +10481,21 @@ const docTemplate = `{ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": [ @@ -11094,6 +11138,28 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -11160,6 +11226,17 @@ const docTemplate = `{ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -11677,7 +11754,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -12214,7 +12291,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 582ccc74f22c3..d6684e7cf6c18 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3775,6 +3775,28 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/users/{user}": { @@ -8545,16 +8567,23 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["example", "auto-fill-parameters", "multi-organization"], + "enum": [ + "example", + "auto-fill-parameters", + "multi-organization", + "custom-roles" + ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { @@ -9316,7 +9345,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -9391,6 +9420,21 @@ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": ["callback_url", "name"], @@ -9996,6 +10040,28 @@ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -10062,6 +10128,17 @@ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -10559,7 +10636,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -11053,7 +11130,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/audit.go b/coderd/audit.go index 782c977afcf1c..315913dff49c2 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -196,12 +196,12 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs CreatedAt: dblog.UserCreatedAt.Time, Status: codersdk.UserStatus(dblog.UserStatus.UserStatus), }, - Roles: []codersdk.Role{}, + Roles: []codersdk.SlimRole{}, } for _, roleName := range dblog.UserRoles { rbacRole, _ := rbac.RoleByName(roleName) - user.Roles = append(user.Roles, db2sdk.Role(rbacRole)) + user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole)) } } diff --git a/coderd/coderd.go b/coderd/coderd.go index c0631c0752c0c..f30d58f8cd726 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -61,6 +61,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" @@ -631,6 +632,7 @@ func New(options *Options) *API { httpmw.AttachRequestID, httpmw.ExtractRealIP(api.RealIPConfig), httpmw.Logger(api.Logger), + rolestore.CustomRoleMW, prometheusMW, // Build-Version is helpful for debugging. func(next http.Handler) http.Handler { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 14a24e66316d0..ab6f3aa82b3f6 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -28,9 +29,25 @@ import ( // database types to slices of codersdk types. // Only works if the function takes a single argument. func List[F any, T any](list []F, convert func(F) T) []T { - into := make([]T, 0, len(list)) - for _, item := range list { - into = append(into, convert(item)) + return ListLazy(convert)(list) +} + +// ListLazy returns the converter function for a list, but does not eval +// the input. Helpful for combining the Map and the List functions. +func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { + return func(list []F) []T { + into := make([]T, 0, len(list)) + for _, item := range list { + into = append(into, convert(item)) + } + return into + } +} + +func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { + into := make(map[K]T) + for k, item := range params { + into[k] = convert(item) } return into } @@ -150,12 +167,20 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: make([]codersdk.Role, 0, len(user.RBACRoles)), + Roles: make([]codersdk.SlimRole, 0, len(user.RBACRoles)), } for _, roleName := range user.RBACRoles { - rbacRole, _ := rbac.RoleByName(roleName) - convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole)) + rbacRole, err := rbac.RoleByName(roleName) + if err == nil { + convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole)) + } else { + // TODO: Fix this for custom roles to display the actual display_name + // Requires plumbing either a cached role value, or the db. + convertedUser.Roles = append(convertedUser.Roles, codersdk.SlimRole{ + Name: roleName, + }) + } } return convertedUser @@ -180,8 +205,8 @@ func Group(group database.Group, members []database.User) codersdk.Group { } } -func Role(role rbac.Role) codersdk.Role { - return codersdk.Role{ +func SlimRole(role rbac.Role) codersdk.SlimRole { + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -500,3 +525,39 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner } return result } + +func Role(role rbac.Role) codersdk.Role { + return codersdk.Role{ + Name: role.Name, + DisplayName: role.DisplayName, + SitePermissions: List(role.Site, Permission), + OrganizationPermissions: Map(role.Org, ListLazy(Permission)), + UserPermissions: List(role.Site, Permission), + } +} + +func Permission(permission rbac.Permission) codersdk.Permission { + return codersdk.Permission{ + Negate: permission.Negate, + ResourceType: codersdk.RBACResource(permission.ResourceType), + Action: codersdk.RBACAction(permission.Action), + } +} + +func RoleToRBAC(role codersdk.Role) rbac.Role { + return rbac.Role{ + Name: role.Name, + DisplayName: role.DisplayName, + Site: List(role.SitePermissions, PermissionToRBAC), + Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)), + User: List(role.UserPermissions, PermissionToRBAC), + } +} + +func PermissionToRBAC(permission codersdk.Permission) rbac.Permission { + return rbac.Permission{ + Negate: permission.Negate, + ResourceType: string(permission.ResourceType), + Action: policy.Action(permission.Action), + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 92b9637e9ddf9..d59f34ab16ad7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -620,7 +620,8 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } if len(customRoles) > 0 { - expandedCustomRoles, err := q.CustomRolesByName(ctx, customRoles) + // Leverage any custom role cache that might exist. + expandedCustomRoles, err := rolestore.Expand(ctx, q.db, customRoles) if err != nil { return xerrors.Errorf("fetching custom roles: %w", err) } diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 0bd064bf8e28a..5bb45424b57f9 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -437,11 +438,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + //nolint:gocritic // Permission to lookup custom roles the user has assigned. + rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roles.Roles) + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to expand authenticated user roles", + Detail: err.Error(), + Validations: nil, + }) + } + // Actor is the user's authorization context. actor := rbac.Subject{ FriendlyName: roles.Username, ID: key.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbacRoles, Groups: roles.Groups, Scope: rbac.ScopeName(key.Scope), }.WithCachedASTValue() diff --git a/coderd/members.go b/coderd/members.go index 038851870cf8d..6a3fe3b2bcb09 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -82,7 +82,7 @@ func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database } if _, err := rbac.RoleByName(r); err != nil { - return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported role", r) + return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r) } } @@ -99,12 +99,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz OrganizationID: mem.OrganizationID, CreatedAt: mem.CreatedAt, UpdatedAt: mem.UpdatedAt, - Roles: make([]codersdk.Role, 0, len(mem.Roles)), + Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)), } for _, roleName := range mem.Roles { rbacRole, _ := rbac.RoleByName(roleName) - convertedMember.Roles = append(convertedMember.Roles, db2sdk.Role(rbacRole)) + convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) } return convertedMember } diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 5cf69bcd41fde..0ed8b2f12fcdb 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -1,14 +1,96 @@ package rolestore import ( + "context" "encoding/json" + "net/http" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/syncmap" ) +type customRoleCtxKey struct{} + +// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate +// db fetches. +func CustomRoleMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(CustomRoleCacheContext(r.Context())) + next.ServeHTTP(w, r) + }) +} + +// CustomRoleCacheContext prevents needing to lookup custom roles within the +// same request lifecycle. Optimizing this to span requests should be done +// in the future. +func CustomRoleCacheContext(ctx context.Context) context.Context { + return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]()) +} + +func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { + c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role]) + if !ok { + return syncmap.New[string, rbac.Role]() + } + return c +} + +// Expand will expand built in roles, and fetch custom roles from the database. +func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { + if len(names) == 0 { + // That was easy + return []rbac.Role{}, nil + } + + cache := roleCache(ctx) + lookup := make([]string, 0) + roles := make([]rbac.Role, 0, len(names)) + + for _, name := range names { + // Remove any built in roles + expanded, err := rbac.RoleByName(name) + if err == nil { + roles = append(roles, expanded) + continue + } + + // Check custom role cache + customRole, ok := cache.Load(name) + if ok { + roles = append(roles, customRole) + continue + } + + // Defer custom role lookup + lookup = append(lookup, name) + } + + if len(lookup) > 0 { + // If some roles are missing from the database, they are omitted from + // the expansion. These roles are no-ops. Should we raise some kind of + // warning when this happens? + dbroles, err := db.CustomRolesByName(ctx, lookup) + if err != nil { + return nil, xerrors.Errorf("fetch custom roles: %w", err) + } + + // convert dbroles -> roles + for _, dbrole := range dbroles { + converted, err := ConvertDBRole(dbrole) + if err != nil { + return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) + } + roles = append(roles, converted) + cache.Store(dbrole.Name, converted) + } + } + + return roles, nil +} + func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { role := rbac.Role{ Name: dbRole.Name, @@ -35,3 +117,30 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { return role, nil } + +func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) { + dbRole := database.CustomRole{ + Name: role.Name, + DisplayName: role.DisplayName, + } + + siteData, err := json.Marshal(role.Site) + if err != nil { + return dbRole, xerrors.Errorf("marshal site permissions: %w", err) + } + dbRole.SitePermissions = siteData + + orgData, err := json.Marshal(role.Org) + if err != nil { + return dbRole, xerrors.Errorf("marshal org permissions: %w", err) + } + dbRole.OrgPermissions = orgData + + userData, err := json.Marshal(role.User) + if err != nil { + return dbRole, xerrors.Errorf("marshal user permissions: %w", err) + } + dbRole.UserPermissions = userData + + return dbRole, nil +} diff --git a/coderd/roles.go b/coderd/roles.go index 5665e298f0e5d..d18c8f0991611 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -66,7 +66,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder continue } assignable = append(assignable, codersdk.AssignableRoles{ - Role: codersdk.Role{ + SlimRole: codersdk.SlimRole{ Name: role.Name, DisplayName: role.DisplayName, }, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index c50f24eb467a0..6754ddc17c9c2 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -143,9 +143,9 @@ func TestListRoles(t *testing.T) { } } -func convertRole(roleName string) codersdk.Role { +func convertRole(roleName string) codersdk.SlimRole { role, _ := rbac.RoleByName(roleName) - return codersdk.Role{ + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -156,7 +156,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { for roleName, assignable := range assignableRoles { role := convertRole(roleName) converted = append(converted, codersdk.AssignableRoles{ - Role: role, + SlimRole: role, Assignable: assignable, }) } diff --git a/coderd/users.go b/coderd/users.go index c8ca04e390c7f..8db74cadadc9b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1095,7 +1095,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - updatedUser, err := UpdateSiteUserRoles(ctx, api.Database, database.UpdateUserRolesParams{ + updatedUser, err := api.Database.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, }) @@ -1123,27 +1123,6 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs)) } -// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments. -// If an organization role is included, an error is returned. -func UpdateSiteUserRoles(ctx context.Context, db database.Store, args database.UpdateUserRolesParams) (database.User, error) { - // Enforce only site wide roles. - for _, r := range args.GrantedRoles { - if _, ok := rbac.IsOrgRole(r); ok { - return database.User{}, xerrors.Errorf("Must only update site wide roles") - } - - if _, err := rbac.RoleByName(r); err != nil { - return database.User{}, xerrors.Errorf("%q is not a supported role", r) - } - } - - updatedUser, err := db.UpdateUserRoles(ctx, args) - if err != nil { - return database.User{}, xerrors.Errorf("update site roles: %w", err) - } - return updatedUser, nil -} - // Returns organizations the parameterized user has access to. // // @Summary Get organizations by user diff --git a/coderd/users_test.go b/coderd/users_test.go index 588a2e107566b..01cac4d1c8251 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1049,7 +1049,7 @@ func TestGrantSiteRoles(t *testing.T) { c.AssignToUser = newUser.ID.String() } - var newRoles []codersdk.Role + var newRoles []codersdk.SlimRole if c.OrgID != uuid.Nil { // Org assign var mem codersdk.OrganizationMember diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8f8499e51f13b..dd52cae77d1b4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -54,6 +54,7 @@ const ( FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" FeatureAccessControl FeatureName = "access_control" FeatureControlSharedPorts FeatureName = "control_shared_ports" + FeatureCustomRoles FeatureName = "custom_roles" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -74,6 +75,7 @@ var FeatureNames = []FeatureName{ FeatureWorkspaceBatchActions, FeatureAccessControl, FeatureControlSharedPorts, + FeatureCustomRoles, } // Humanize returns the feature name in a human-readable format. @@ -98,6 +100,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureAppearance: true, FeatureWorkspaceBatchActions: true, FeatureHighAvailability: true, + FeatureCustomRoles: true, }[n] } @@ -2218,6 +2221,7 @@ const ( ExperimentExample Experiment = "example" // This isn't used for anything. ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. + ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles ) // ExperimentsAll should include all experiments that are safe for diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 441f4774f2441..4c9cf81c497d3 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -48,11 +48,11 @@ type Organization struct { } type OrganizationMember struct { - UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` - CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` - Roles []Role `db:"roles" json:"roles"` + UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` + CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` + Roles []SlimRole `db:"roles" json:"roles"` } // CreateTemplateVersionRequest enables callers to create a new Template Version. diff --git a/codersdk/roles.go b/codersdk/roles.go index 5ed9a92539654..b70455b80973a 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -9,16 +9,52 @@ import ( "github.com/google/uuid" ) -type Role struct { +// SlimRole omits permission information from a role. +// At present, this is because our apis do not return permission information, +// and it would require extra db calls to fetch this information. The UI does +// not need it, so most api calls will use this structure that omits information. +type SlimRole struct { Name string `json:"name"` DisplayName string `json:"display_name"` } type AssignableRoles struct { - Role + SlimRole Assignable bool `json:"assignable"` } +// Permission is the format passed into the rego. +type Permission struct { + // Negate makes this a negative permission + Negate bool `json:"negate"` + ResourceType RBACResource `json:"resource_type"` + Action RBACAction `json:"action"` +} + +// Role is a longer form of SlimRole used to edit custom roles. +type Role struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + SitePermissions []Permission `json:"site_permissions"` + // map[] -> Permissions + OrganizationPermissions map[string][]Permission `json:"organization_permissions"` + UserPermissions []Permission `json:"user_permissions"` +} + +// UpsertCustomSiteRole will upsert a custom site wide role +func (c *Client) UpsertCustomSiteRole(ctx context.Context, req Role) (Role, error) { + res, err := c.Request(ctx, http.MethodPatch, "/api/v2/users/roles", req) + if err != nil { + return Role{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Role{}, ReadBodyAsError(res) + } + var role Role + return role, json.NewDecoder(res.Body).Decode(&role) +} + // ListSiteRoles lists all assignable site wide roles. func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) @@ -46,3 +82,17 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]As var roles []AssignableRoles return roles, json.NewDecoder(res.Body).Decode(&roles) } + +// CreatePermissions is a helper function to quickly build permissions. +func CreatePermissions(mapping map[RBACResource][]RBACAction) []Permission { + perms := make([]Permission, 0) + for t, actions := range mapping { + for _, action := range actions { + perms = append(perms, Permission{ + ResourceType: t, + Action: action, + }) + } + } + return perms +} diff --git a/codersdk/users.go b/codersdk/users.go index 7eb7604fc57b7..80ca583141c9b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -63,7 +63,7 @@ type User struct { ReducedUser `table:"r,recursive_inline"` OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"` - Roles []Role `json:"roles"` + Roles []SlimRole `json:"roles"` } type GetUsersResponse struct { diff --git a/docs/api/members.md b/docs/api/members.md index e44056664588a..43ae4e8f23da1 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -154,3 +154,127 @@ Status Code **200** | `» name` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert a custom site-wide role + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/users/roles \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /users/roles` + +### Example responses + +> 200 Response + +```json +[ + { + "display_name": "string", + "name": "string", + "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_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). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cd5c1366e392a..a50962cba09e7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2694,6 +2694,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `example` | | `auto-fill-parameters` | | `multi-organization` | +| `custom-roles` | ## codersdk.ExternalAuth @@ -3579,13 +3580,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | --------------------------------------- | -------- | ------------ | ----------- | -| `created_at` | string | false | | | -| `organization_id` | string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `updated_at` | string | false | | | -| `user_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | ## codersdk.PatchGroupRequest @@ -3649,6 +3650,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `name` | string | true | | | | `regenerate_token` | boolean | false | | | +## codersdk.Permission + +```json +{ + "action": "application_connect", + "negate": true, + "resource_type": "*" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------- | ---------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `action` | [codersdk.RBACAction](#codersdkrbacaction) | false | | | +| `negate` | boolean | false | | Negate makes this a negative permission | +| `resource_type` | [codersdk.RBACResource](#codersdkrbacresource) | false | | | + ## codersdk.PostOAuth2ProviderAppRequest ```json @@ -4271,16 +4290,50 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { "display_name": "string", - "name": "string" + "name": "string", + "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": "*" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------ | -------- | ------------ | ----------- | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_permissions` | object | false | | map[] -> Permissions | +| » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.SSHConfig @@ -4356,6 +4409,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | | `max_token_lifetime` | integer | false | | | +## codersdk.SlimRole + +```json +{ + "display_name": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `name` | string | false | | | + ## codersdk.SupportConfig ```json @@ -4558,7 +4627,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "times_used": 2, "type": "builtin" } ``` @@ -4572,7 +4640,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `seconds` | integer | false | | | | `slug` | string | false | | | | `template_ids` | array of string | false | | | -| `times_used` | integer | false | | | | `type` | [codersdk.TemplateAppsType](#codersdktemplateappstype) | false | | | ## codersdk.TemplateAppsType @@ -4702,7 +4769,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "times_used": 2, "type": "builtin" } ], @@ -4768,7 +4834,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "times_used": 2, "type": "builtin" } ], @@ -4906,21 +4971,21 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------------- | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -5497,20 +5562,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------------------------------------------ | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 56c774911018b..9b2ad0752c37b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -326,6 +326,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) + + r.Route("/users/roles", func(r chi.Router) { + r.Use( + api.customRolesEnabledMW, + apiKeyMiddleware, + ) + + r.Patch("/", api.patchRole) + }) + r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( api.autostopRequirementEnabledMW, diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go new file mode 100644 index 0000000000000..2224e7f25c0bf --- /dev/null +++ b/enterprise/coderd/roles.go @@ -0,0 +1,80 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" +) + +// patchRole will allow creating a custom role +// +// @Summary Upsert a custom site-wide role +// @ID upsert-a-custom-site-wide-role +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Success 200 {array} codersdk.Role +// @Router /users/roles [patch] +func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req codersdk.Role + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if len(req.OrganizationPermissions) > 0 { + // Org perms should be assigned only in org specific roles. Otherwise, + // it gets complicated to keep track of who can do what. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, not allowed to assign organization permissions for a site wide role.", + Detail: "site wide roles may not contain organization specific permissions", + }) + return + } + + // Make sure all permissions inputted are valid according to our policy. + rbacRole := db2sdk.RoleToRBAC(req) + args, err := rolestore.ConvertRoleToDB(rbacRole) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Detail: err.Error(), + }) + return + } + + inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: args.Name, + DisplayName: args.DisplayName, + SitePermissions: args.SitePermissions, + OrgPermissions: args.OrgPermissions, + UserPermissions: args.UserPermissions, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update role permissions", + Detail: err.Error(), + }) + return + } + + 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 + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) +} diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go new file mode 100644 index 0000000000000..57bac91260012 --- /dev/null +++ b/enterprise/coderd/roles_test.go @@ -0,0 +1,63 @@ +package coderd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestCustomRole(t *testing.T) { + t.Parallel() + + // Create, assign, and use a custom role + //nolint:gocritic + t.Run("Success", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ + Name: "test-role", + DisplayName: "Testing Purposes", + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.NoError(t, err, "upsert role") + + // Assign the custom template admin role + tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + + // Assert the role exists + roleNamesF := func(role codersdk.SlimRole) string { return role.Name } + require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name) + + // Try to create a template version + coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) + }) +} diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index f35d38ca448d9..a2dcac6085c2a 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -7,7 +7,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/codersdk" @@ -96,7 +95,7 @@ func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db dat // Should this be feature protected? return db.InTx(func(tx database.Store) error { - _, err := coderd.UpdateSiteUserRoles(ctx, db, database.UpdateUserRolesParams{ + _, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: roles, ID: userID, }) diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 935eeb8f6e689..a29aa1836557d 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -14,6 +14,31 @@ import ( "github.com/coder/coder/v2/codersdk" ) +func (api *API) customRolesEnabledMW(next http.Handler) http.Handler { + return httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentCustomRoles)( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Entitlement must be enabled. + api.entitlementsMu.RLock() + entitled := api.entitlements.Features[codersdk.FeatureCustomRoles].Entitlement != codersdk.EntitlementNotEntitled + enabled := api.entitlements.Features[codersdk.FeatureCustomRoles].Enabled + api.entitlementsMu.RUnlock() + if !entitled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is an Enterprise feature. Contact sales!", + }) + return + } + if !enabled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is not enabled", + }) + return + } + + next.ServeHTTP(rw, r) + })) +} + func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Entitlement must be enabled. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed7f18ef1472c..8baa6a5edfc1c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1102,7 +1102,7 @@ class ApiMethods { }; updateUserRoles = async ( - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], userId: TypesGen.User["id"], ): Promise => { const response = await this.axios.put( diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b3280d200328a..08b1ac2732d82 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -65,7 +65,7 @@ export interface ArchiveTemplateVersionsResponse { } // From codersdk/roles.go -export interface AssignableRoles extends Role { +export interface AssignableRoles extends SlimRole { readonly assignable: boolean; } @@ -786,7 +786,7 @@ export interface OrganizationMember { readonly organization_id: string; readonly created_at: string; readonly updated_at: string; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/pagination.go @@ -821,6 +821,13 @@ export interface PatchWorkspaceProxy { readonly regenerate_token: boolean; } +// From codersdk/roles.go +export interface Permission { + readonly negate: boolean; + readonly resource_type: RBACResource; + readonly action: RBACAction; +} + // From codersdk/oauth2.go export interface PostOAuth2ProviderAppRequest { readonly name: string; @@ -970,6 +977,9 @@ export interface Response { export interface Role { readonly name: string; readonly display_name: string; + readonly site_permissions: readonly Permission[]; + readonly organization_permissions: Record; + readonly user_permissions: readonly Permission[]; } // From codersdk/deployment.go @@ -1014,6 +1024,12 @@ export interface SessionLifetime { readonly max_token_lifetime?: number; } +// From codersdk/roles.go +export interface SlimRole { + readonly name: string; + readonly display_name: string; +} + // From codersdk/deployment.go export interface SupportConfig { readonly links: readonly LinkConfig[]; @@ -1405,7 +1421,7 @@ export interface UpsertWorkspaceAgentPortShareRequest { // From codersdk/users.go export interface User extends ReducedUser { readonly organization_ids: readonly string[]; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/insights.go @@ -1910,10 +1926,12 @@ export const Entitlements: Entitlement[] = [ // From codersdk/deployment.go export type Experiment = | "auto-fill-parameters" + | "custom-roles" | "example" | "multi-organization"; export const Experiments: Experiment[] = [ "auto-fill-parameters", + "custom-roles", "example", "multi-organization", ]; @@ -1926,6 +1944,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "control_shared_ports" + | "custom_roles" | "external_provisioner_daemons" | "external_token_encryption" | "high_availability" @@ -1943,6 +1962,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "control_shared_ports", + "custom_roles", "external_provisioner_daemons", "external_token_encryption", "high_availability", diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index ebc5e24a5e6b6..edbc0118b09f2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { API } from "api/api"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { MockUser, MockUser2, @@ -102,7 +102,7 @@ const resetUserPassword = async (setupActionSpies: () => void) => { fireEvent.click(confirmButton); }; -const updateUserRole = async (role: Role) => { +const updateUserRole = async (role: SlimRole) => { // Get the first user in the table const users = await screen.findAllByText(/.*@coder.com/); const userRow = users[0].closest("tr"); diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9349311392146..be5f50b6ff9b8 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -25,7 +25,7 @@ export interface UsersPageViewProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; filterProps: ComponentProps; isNonInitialPage: boolean; diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx index 76099c9796205..b304bbed01f89 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx @@ -3,7 +3,7 @@ import UserIcon from "@mui/icons-material/PersonOutline"; import Checkbox from "@mui/material/Checkbox"; import IconButton from "@mui/material/IconButton"; import type { FC } from "react"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { HelpTooltip, HelpTooltipContent, @@ -69,9 +69,9 @@ const Option: FC = ({ export interface EditRolesButtonProps { isLoading: boolean; - roles: readonly Role[]; + roles: readonly SlimRole[]; selectedRoleNames: Set; - onChange: (roles: Role["name"][]) => void; + onChange: (roles: SlimRole["name"][]) => void; isDefaultOpen?: boolean; oidcRoleSync: boolean; userLoginType: string; diff --git a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx index 36090dbfdeff8..398354f94ee69 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx @@ -17,7 +17,7 @@ import { useTheme } from "@emotion/react"; import Stack from "@mui/material/Stack"; import TableCell from "@mui/material/TableCell"; import type { FC } from "react"; -import type { Role, User } from "api/typesGenerated"; +import type { SlimRole, User } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; import { Popover, @@ -28,7 +28,7 @@ import { EditRolesButton } from "./EditRolesButton"; type UserRoleCellProps = { canEditUsers: boolean; - allAvailableRoles: Role[] | undefined; + allAvailableRoles: SlimRole[] | undefined; user: User; isLoading: boolean; oidcRoleSyncEnabled: boolean; @@ -90,7 +90,7 @@ export const UserRoleCell: FC = ({ }; type OverflowRolePillProps = { - roles: readonly Role[]; + roles: readonly SlimRole[]; }; const OverflowRolePill: FC = ({ roles }) => { @@ -148,7 +148,7 @@ const OverflowRolePill: FC = ({ roles }) => { ); }; -const fallbackRole: Role = { +const fallbackRole: SlimRole = { name: "member", display_name: "Member", } as const; @@ -160,7 +160,9 @@ const roleNamesByAccessLevel: readonly string[] = [ "auditor", ]; -function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { +function sortRolesByAccessLevel( + roles: readonly SlimRole[], +): readonly SlimRole[] { if (roles.length === 0) { return roles; } @@ -172,7 +174,7 @@ function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { ); } -function getSelectedRoleNames(roles: readonly Role[]) { +function getSelectedRoleNames(roles: readonly SlimRole[]) { const roleNameSet = new Set(roles.map((role) => role.name)); if (roleNameSet.size === 0) { roleNameSet.add(fallbackRole.name); diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 119b00a851146..d3748f2d8ea95 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -36,7 +36,7 @@ export interface UsersTableProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 03222edbedd6d..03a99bd423bf9 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -52,7 +52,7 @@ interface UsersTableBodyProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6cf97131aba67..5fe1e9cc7b0ff 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -229,27 +229,27 @@ export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { version: "v99.999.9999+c9cdf14", }; -export const MockOwnerRole: TypesGen.Role = { +export const MockOwnerRole: TypesGen.SlimRole = { name: "owner", display_name: "Owner", }; -export const MockUserAdminRole: TypesGen.Role = { +export const MockUserAdminRole: TypesGen.SlimRole = { name: "user_admin", display_name: "User Admin", }; -export const MockTemplateAdminRole: TypesGen.Role = { +export const MockTemplateAdminRole: TypesGen.SlimRole = { name: "template_admin", display_name: "Template Admin", }; -export const MockMemberRole: TypesGen.Role = { +export const MockMemberRole: TypesGen.SlimRole = { name: "member", display_name: "Member", }; -export const MockAuditorRole: TypesGen.Role = { +export const MockAuditorRole: TypesGen.SlimRole = { name: "auditor", display_name: "Auditor", }; @@ -257,7 +257,7 @@ export const MockAuditorRole: TypesGen.Role = { // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole( - role: TypesGen.Role, + role: TypesGen.SlimRole, assignable: boolean, ): TypesGen.AssignableRoles { return { From 277aa0d302f483035e68e243840ba49a6e13127b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 11:19:33 -0500 Subject: [PATCH 2/4] fix enterprise route override --- coderd/coderd.go | 2 +- coderd/roles.go | 6 +++--- codersdk/roles.go | 4 ++-- enterprise/coderd/coderd.go | 13 ++++++++++--- enterprise/coderd/roles_test.go | 11 ++++++++++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index f30d58f8cd726..80f77d92ee672 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -917,7 +917,7 @@ func New(options *Options) *API { r.Post("/logout", api.postLogout) // These routes query information about site wide roles. r.Route("/roles", func(r chi.Router) { - r.Get("/", api.assignableSiteRoles) + r.Get("/", api.AssignableSiteRoles) }) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) diff --git a/coderd/roles.go b/coderd/roles.go index d18c8f0991611..f90f0e474dddf 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -11,7 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac" ) -// assignableSiteRoles returns all site wide roles that can be assigned. +// AssignableSiteRoles returns all site wide roles that can be assigned. // // @Summary Get site member roles // @ID get-site-member-roles @@ -20,7 +20,7 @@ import ( // @Tags Members // @Success 200 {array} codersdk.AssignableRoles // @Router /users/roles [get] -func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { +func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() actorRoles := httpmw.UserAuthorization(r) if !api.Authorize(r, policy.ActionRead, rbac.ResourceAssignRole) { @@ -32,7 +32,7 @@ func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) } -// assignableSiteRoles returns all org wide roles that can be assigned. +// assignableOrgRoles returns all org wide roles that can be assigned. // // @Summary Get member roles by organization // @ID get-member-roles-by-organization diff --git a/codersdk/roles.go b/codersdk/roles.go index b70455b80973a..90112f7c6ef30 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -41,8 +41,8 @@ type Role struct { UserPermissions []Permission `json:"user_permissions"` } -// UpsertCustomSiteRole will upsert a custom site wide role -func (c *Client) UpsertCustomSiteRole(ctx context.Context, req Role) (Role, error) { +// PatchRole will upsert a custom site wide role +func (c *Client) PatchRole(ctx context.Context, req Role) (Role, error) { res, err := c.Request(ctx, http.MethodPatch, "/api/v2/users/roles", req) if err != nil { return Role{}, err diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 9b2ad0752c37b..524bfd26f3d74 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -329,11 +329,18 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Route("/users/roles", func(r chi.Router) { r.Use( - api.customRolesEnabledMW, apiKeyMiddleware, ) - - r.Patch("/", api.patchRole) + r.Group(func(r chi.Router) { + r.Use( + api.customRolesEnabledMW, + ) + r.Patch("/", api.patchRole) + }) + // Unfortunate, but this r.Route overrides the AGPL roles route. + // The AGPL does not have the entitlements to block the licensed + // routes, so we need to duplicate the AGPL here. + r.Get("/", api.AGPL.AssignableSiteRoles) }) r.Route("/users/{user}/quiet-hours", func(r chi.Router) { diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 57bac91260012..8631c0a2f5484 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -36,7 +36,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ + role, err := owner.PatchRole(ctx, codersdk.Role{ Name: "test-role", DisplayName: "Testing Purposes", // Basically creating a template admin manually @@ -59,5 +59,14 @@ func TestCustomRole(t *testing.T) { // Try to create a template version coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) + + // Verify the role exists in the list + // TODO: Turn this assertion back on when the cli api experience is created. + //allRoles, err := tmplAdmin.ListSiteRoles(ctx) + //require.NoError(t, err) + // + //require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { + // return selected.Name == role.Name + //}), "role missing from site role list") }) } From f92c1ce805dd87126ac1745dcd63d43f0552f64c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 11:43:00 -0500 Subject: [PATCH 3/4] add revoked license test, and remove perms test --- coderd/database/dbauthz/dbauthz.go | 2 +- enterprise/coderd/roles_test.go | 124 ++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d59f34ab16ad7..fe49de61e7e84 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -633,7 +633,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r // Stop at the first one found. We could make a better error that // returns them all, but then someone could pass in a large list to make us do // a lot of loop iterations. - if !slices.ContainsFunc(expandedCustomRoles, func(customRole database.CustomRole) bool { + if !slices.ContainsFunc(expandedCustomRoles, func(customRole rbac.Role) bool { return strings.EqualFold(customRole.Name, role) }) { return xerrors.Errorf("%q is not a supported role", role) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 8631c0a2f5484..450f80e0b7fe3 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -10,14 +11,26 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" ) func TestCustomRole(t *testing.T) { t.Parallel() + templateAdminCustom := codersdk.Role{ + Name: "test-role", + DisplayName: "Testing Purposes", + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + OrganizationPermissions: nil, + UserPermissions: nil, + } // Create, assign, and use a custom role - //nolint:gocritic t.Run("Success", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -36,18 +49,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - role, err := owner.PatchRole(ctx, codersdk.Role{ - Name: "test-role", - DisplayName: "Testing Purposes", - // Basically creating a template admin manually - SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ - codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, - codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, - codersdk.ResourceWorkspace: {codersdk.ActionRead}, - }), - OrganizationPermissions: nil, - UserPermissions: nil, - }) + role, err := owner.PatchRole(ctx, templateAdminCustom) require.NoError(t, err, "upsert role") // Assign the custom template admin role @@ -69,4 +71,100 @@ func TestCustomRole(t *testing.T) { // return selected.Name == role.Name //}), "role missing from site role list") }) + + // Revoked licenses cannot modify/create custom roles, but they can + // use the existing roles. + t.Run("Revoked License", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + role, err := owner.PatchRole(ctx, templateAdminCustom) + require.NoError(t, err, "upsert role") + + // Remove the license to block enterprise functionality + licenses, err := owner.Licenses(ctx) + require.NoError(t, err, "get licenses") + for _, license := range licenses { + // Should be only 1... + err := owner.DeleteLicense(ctx, license.ID) + require.NoError(t, err, "delete license") + } + + // Verify functionality is lost + _, err = owner.PatchRole(ctx, templateAdminCustom) + require.ErrorContains(t, err, "Custom roles is an Enterprise feature", "upsert role") + + // Assign the custom template admin role + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + + // Try to create a template version, eg using the custom role + coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) + }) + + // Role patches are complete, as in the request overrides the existing role. + t.Run("RoleOverrides", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + //nolint:gocritic // owner is required for this + role, err := owner.PatchRole(ctx, templateAdminCustom) + require.NoError(t, err, "upsert role") + + // Assign the custom template admin role + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + + // Try to create a template version, eg using the custom role + coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) + + //nolint:gocritic // owner is required for this + role, err = owner.PatchRole(ctx, codersdk.Role{ + Name: templateAdminCustom.Name, + DisplayName: templateAdminCustom.DisplayName, + // These are all left nil, which sets the custom role to have 0 + // permissions. Omitting this does not "inherit" what already + // exists. + SitePermissions: nil, + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.NoError(t, err, "upsert role with override") + + // The role should no longer have template perms + data, err := echo.TarWithOptions(ctx, tmplAdmin.Logger(), nil) + require.NoError(t, err) + file, err := tmplAdmin.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader(data)) + require.NoError(t, err) + _, err = tmplAdmin.CreateTemplateVersion(ctx, first.OrganizationID, codersdk.CreateTemplateVersionRequest{ + FileID: file.ID, + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.ErrorContains(t, err, "forbidden") + }) } From 68807acf4cf68441f2404f3c177aece99392feea Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 12:37:09 -0500 Subject: [PATCH 4/4] make gen --- docs/api/schemas.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a50962cba09e7..ae35585e2fb12 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4627,6 +4627,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "times_used": 2, "type": "builtin" } ``` @@ -4640,6 +4641,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `seconds` | integer | false | | | | `slug` | string | false | | | | `template_ids` | array of string | false | | | +| `times_used` | integer | false | | | | `type` | [codersdk.TemplateAppsType](#codersdktemplateappstype) | false | | | ## codersdk.TemplateAppsType @@ -4769,6 +4771,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "times_used": 2, "type": "builtin" } ], @@ -4834,6 +4837,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "seconds": 80500, "slug": "vscode", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "times_used": 2, "type": "builtin" } ],