diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 37e121e483068..f373e0079a780 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2225,6 +2225,42 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Upsert a custom organization role", + "operationId": "upsert-a-custom-organization-role", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { @@ -4362,32 +4398,6 @@ 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}": { @@ -8426,13 +8436,10 @@ const docTemplate = `{ "format": "uuid" }, "organization_permissions": { - "description": "map[\u003corg_id\u003e] -\u003e Permissions", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Permission" - } + "description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" } }, "site_permissions": { @@ -11250,13 +11257,10 @@ const docTemplate = `{ "format": "uuid" }, "organization_permissions": { - "description": "map[\u003corg_id\u003e] -\u003e Permissions", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Permission" - } + "description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" } }, "site_permissions": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 293e9e8e65265..84bb41c44fcdd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1942,6 +1942,38 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Upsert a custom organization role", + "operationId": "upsert-a-custom-organization-role", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { @@ -3841,28 +3873,6 @@ } } } - }, - "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}": { @@ -7481,13 +7491,10 @@ "format": "uuid" }, "organization_permissions": { - "description": "map[\u003corg_id\u003e] -\u003e Permissions", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Permission" - } + "description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" } }, "site_permissions": { @@ -10142,13 +10149,10 @@ "format": "uuid" }, "organization_permissions": { - "description": "map[\u003corg_id\u003e] -\u003e Permissions", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Permission" - } + "description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" } }, "site_permissions": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 9ee21a23cf79f..9c748d06eeb71 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -424,6 +424,7 @@ func New(options *Options) *API { TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, + CustomRoleHandler: atomic.Pointer[CustomRoleHandler]{}, Experiments: experiments, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, Acquirer: provisionerdserver.NewAcquirer( @@ -436,6 +437,8 @@ func New(options *Options) *API { workspaceUsageTracker: options.WorkspaceUsageTracker, } + var customRoleHandler CustomRoleHandler = &agplCustomRoleHandler{} + api.CustomRoleHandler.Store(&customRoleHandler) api.AppearanceFetcher.Store(&appearance.DefaultFetcher) api.PortSharer.Store(&portsharing.DefaultPortSharer) buildInfo := codersdk.BuildInfoResponse{ @@ -828,7 +831,12 @@ func New(options *Options) *API { }) }) r.Route("/members", func(r chi.Router) { - r.Get("/roles", api.assignableOrgRoles) + r.Route("/roles", func(r chi.Router) { + r.Get("/", api.assignableOrgRoles) + r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)). + Patch("/", api.patchOrgRoles) + }) + r.Route("/{user}", func(r chi.Router) { r.Use( httpmw.ExtractOrganizationMemberParam(options.Database), @@ -1249,6 +1257,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 590183bd43dd1..2fe9ac9af7a3d 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -531,12 +531,16 @@ func Role(role rbac.Role) codersdk.Role { if err != nil { roleName = role.Name } + return codersdk.Role{ - Name: roleName, - OrganizationID: orgIDStr, - DisplayName: role.DisplayName, - SitePermissions: List(role.Site, Permission), - OrganizationPermissions: Map(role.Org, ListLazy(Permission)), + Name: roleName, + OrganizationID: orgIDStr, + DisplayName: role.DisplayName, + SitePermissions: List(role.Site, Permission), + // This is not perfect. If there are organization permissions in another + // organization, they will be omitted. This should not be allowed, so + // should never happen. + OrganizationPermissions: List(role.Org[orgIDStr], Permission), UserPermissions: List(role.User, Permission), } } @@ -550,11 +554,18 @@ func Permission(permission rbac.Permission) codersdk.Permission { } func RoleToRBAC(role codersdk.Role) rbac.Role { + orgPerms := map[string][]rbac.Permission{} + if role.OrganizationID != "" { + orgPerms = map[string][]rbac.Permission{ + role.OrganizationID: List(role.OrganizationPermissions, PermissionToRBAC), + } + } + return rbac.Role{ Name: rbac.RoleName(role.Name, role.OrganizationID), DisplayName: role.DisplayName, Site: List(role.SitePermissions, PermissionToRBAC), - Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)), + Org: orgPerms, User: List(role.UserPermissions, PermissionToRBAC), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0ab78e75fe196..ec9d14bb57de6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -600,7 +600,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r customRoles := make([]string, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { - _, isOrgRole := rbac.IsOrgRole(r) + roleOrgIDStr, isOrgRole := rbac.IsOrgRole(r) if shouldBeOrgRoles && !isOrgRole { return xerrors.Errorf("Must only update org roles") } @@ -608,6 +608,21 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r return xerrors.Errorf("Must only update site wide roles") } + if shouldBeOrgRoles { + roleOrgID, err := uuid.Parse(roleOrgIDStr) + if err != nil { + return xerrors.Errorf("role %q has invalid uuid for org: %w", r, err) + } + + if orgID == nil { + return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role") + } + + if roleOrgID != *orgID { + return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String()) + } + } + // All roles should be valid roles if _, err := rbac.RoleByName(r); err != nil { customRoles = append(customRoles, r) diff --git a/coderd/members.go b/coderd/members.go index 6a3fe3b2bcb09..beae302ab3124 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -1,13 +1,8 @@ package coderd import ( - "context" "net/http" - "github.com/google/uuid" - - "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" @@ -48,7 +43,7 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { return } - updatedUser, err := api.updateOrganizationMemberRoles(ctx, database.UpdateMemberRolesParams{ + updatedUser, err := api.Database.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ GrantedRoles: params.Roles, UserID: member.UserID, OrgID: organization.ID, @@ -63,36 +58,6 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertOrganizationMember(updatedUser)) } -func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database.UpdateMemberRolesParams) (database.OrganizationMember, error) { - // Enforce only site wide roles - for _, r := range args.GrantedRoles { - // Must be an org role for the org in the args - orgID, ok := rbac.IsOrgRole(r) - if !ok { - return database.OrganizationMember{}, xerrors.Errorf("must only update organization roles") - } - - roleOrg, err := uuid.Parse(orgID) - if err != nil { - return database.OrganizationMember{}, xerrors.Errorf("Role must have proper UUIDs for organization, %q does not", r) - } - - if roleOrg != args.OrgID { - return database.OrganizationMember{}, xerrors.Errorf("Must only pass roles for org %q", args.OrgID.String()) - } - - if _, err := rbac.RoleByName(r); err != nil { - return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r) - } - } - - updatedUser, err := api.Database.UpdateMemberRoles(ctx, args) - if err != nil { - return database.OrganizationMember{}, xerrors.Errorf("Update site roles: %w", err) - } - return updatedUser, nil -} - func convertOrganizationMember(mem database.OrganizationMember) codersdk.OrganizationMember { convertedMember := codersdk.OrganizationMember{ UserID: mem.UserID, diff --git a/coderd/roles.go b/coderd/roles.go index a00af23ce98eb..e8505baa4d255 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "net/http" "github.com/google/uuid" @@ -16,6 +17,52 @@ 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 with AGPL endpoints. +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, _ database.Store, rw http.ResponseWriter, _ uuid.UUID, _ 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 +// @Param organization path string true "Organization ID" format(uuid) +// @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 + } + + 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 diff --git a/codersdk/roles.go b/codersdk/roles.go index c803e92f44bb2..8b119e935a6c6 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -39,14 +39,26 @@ type Role struct { 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 - OrganizationPermissions map[string][]Permission `json:"organization_permissions" table:"org_permissions"` - UserPermissions []Permission `json:"user_permissions" table:"user_permissions"` + // OrganizationPermissions are specific for the organization in the field 'OrganizationID' above. + OrganizationPermissions []Permission `json:"organization_permissions" table:"org_permissions"` + UserPermissions []Permission `json:"user_permissions" table:"user_permissions"` } -// 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) +// FullName returns the role name scoped to the organization ID. This is useful if +// printing a set of roles from different scopes, as duplicated names across multiple +// scopes will become unique. +// In practice, this is primarily used in testing. +func (r Role) FullName() string { + if r.OrganizationID == "" { + return r.Name + } + return r.Name + ":" + r.OrganizationID +} + +// PatchOrganizationRole will upsert a custom organization role +func (c *Client) PatchOrganizationRole(ctx context.Context, organizationID uuid.UUID, req Role) (Role, error) { + res, err := c.Request(ctx, http.MethodPatch, + fmt.Sprintf("/api/v2/organizations/%s/members/roles", organizationID.String()), req) if err != nil { return Role{}, err } diff --git a/docs/api/members.md b/docs/api/members.md index 27536a6c836fa..6364b08ca528e 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -31,22 +31,13 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "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": "*" - } - ] - }, + "organization_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], "site_permissions": [ { "action": "application_connect", @@ -75,21 +66,20 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» 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 | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_permissions` | array | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. | +| `»» 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 @@ -136,75 +126,24 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Assign role to organization member +## Upsert a custom organization role ### Code samples ```shell # Example request using curl -curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/roles \ - -H 'Content-Type: application/json' \ +curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/members/roles \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PUT /organizations/{organization}/members/{user}/roles` - -> Body parameter - -```json -{ - "roles": ["string"] -} -``` +`PATCH /organizations/{organization}/members/roles` ### Parameters -| Name | In | Type | Required | Description | -| -------------- | ---- | ------------------------------------------------------ | -------- | -------------------- | -| `organization` | path | string | true | Organization ID | -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.UpdateRoles](schemas.md#codersdkupdateroles) | true | Update roles request | - -### Example responses - -> 200 Response - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "roles": [ - { - "display_name": "string", - "name": "string" - } - ], - "updated_at": "2019-08-24T14:15:22Z", - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get site member roles - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/roles \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /users/roles` +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | --------------- | +| `organization` | path | string(uuid) | true | Organization ID | ### Example responses @@ -213,27 +152,16 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ ```json [ { - "assignable": true, - "built_in": true, "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": "*" - } - ] - }, + "organization_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], "site_permissions": [ { "action": "application_connect", @@ -254,29 +182,26 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AssignableRoles](schemas.md#codersdkassignableroles) | +| 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

+

Response Schema

Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» 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 | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_permissions` | array | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. | +| `»» 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 @@ -323,18 +248,75 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Upsert a custom site-wide role +## Assign role to organization member + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/roles \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /organizations/{organization}/members/{user}/roles` + +> Body parameter + +```json +{ + "roles": ["string"] +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------------------------------------------------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.UpdateRoles](schemas.md#codersdkupdateroles) | true | Update roles request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get site member roles ### Code samples ```shell # Example request using curl -curl -X PATCH http://coder-server:8080/api/v2/users/roles \ +curl -X GET http://coder-server:8080/api/v2/users/roles \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PATCH /users/roles` +`GET /users/roles` ### Example responses @@ -343,25 +325,18 @@ curl -X PATCH http://coder-server:8080/api/v2/users/roles \ ```json [ { + "assignable": true, + "built_in": true, "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": "*" - } - ] - }, + "organization_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], "site_permissions": [ { "action": "application_connect", @@ -382,27 +357,28 @@ curl -X PATCH http://coder-server:8080/api/v2/users/roles \ ### 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) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AssignableRoles](schemas.md#codersdkassignableroles) | -

Response Schema

+

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 | | | -| `»»» 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 | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» organization_permissions` | array | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. | +| `»» 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 diff --git a/docs/api/schemas.md b/docs/api/schemas.md index ca7493ae53ec0..978da35a58d02 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -806,22 +806,13 @@ "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": "*" - } - ] - }, + "organization_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], "site_permissions": [ { "action": "application_connect", @@ -841,17 +832,16 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | -| `assignable` | boolean | false | | | -| `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 | | | -| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `assignable` | boolean | false | | | +| `built_in` | boolean | false | | Built in roles are immutable | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.AuditAction @@ -4330,22 +4320,13 @@ 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": [ - { - "action": "application_connect", - "negate": true, - "resource_type": "*" - } - ], - "property2": [ - { - "action": "application_connect", - "negate": true, - "resource_type": "*" - } - ] - }, + "organization_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], "site_permissions": [ { "action": "application_connect", @@ -4365,15 +4346,14 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | -| `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 | | | -| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.SSHConfig diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 524bfd26f3d74..574d2c12dd2de 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -327,22 +327,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) }) - r.Route("/users/roles", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - ) - 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) { r.Use( api.autostopRequirementEnabledMW, @@ -761,6 +745,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 8e0827c9b3b02..448ec9f855cc0 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -1,6 +1,8 @@ package coderd import ( + "context" + "fmt" "net/http" "github.com/google/uuid" @@ -12,70 +14,83 @@ import ( "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() +type enterpriseCustomRoleHandler struct { + Enabled bool +} - var req codersdk.Role - if !httpapi.Read(ctx, rw, r, &req) { - return +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 are not enabled", + }) + return codersdk.Role{}, false } - if err := httpapi.NameValid(req.Name); err != nil { + if err := httpapi.NameValid(role.Name); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid role name", Detail: err.Error(), }) - return + 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(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. + if role.OrganizationID != orgID.String() { 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", + Message: "Invalid request, organization in role and url must match", + Detail: fmt.Sprintf("role organization=%q does not match URL=%q", role.OrganizationID, orgID.String()), }) - return + return codersdk.Role{}, false } // Make sure all permissions inputted are valid according to our policy. - rbacRole := db2sdk.RoleToRBAC(req) + 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 + return codersdk.Role{}, false } - inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ - Name: args.Name, - DisplayName: args.DisplayName, - OrganizationID: uuid.NullUUID{}, + 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 + 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 + return codersdk.Role{}, false } convertedInsert, err := rolestore.ConvertDBRole(inserted) @@ -84,8 +99,8 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { Message: "Permissions were updated, unable to read them back out of the database.", Detail: err.Error(), }) - return + return codersdk.Role{}, false } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) + return db2sdk.Role(convertedInsert), true } diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index a7db9b718d946..e1d6855aff002 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -5,10 +5,10 @@ import ( "slices" "testing" + "github.com/google/uuid" "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" @@ -16,19 +16,22 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestCustomRole(t *testing.T) { +func TestCustomOrganizationRole(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, + templateAdminCustom := func(orgID uuid.UUID) codersdk.Role { + return codersdk.Role{ + Name: "test-role", + DisplayName: "Testing Purposes", + OrganizationID: orgID.String(), + // Basically creating a template admin manually + SitePermissions: nil, + OrganizationPermissions: 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}, + }), + UserPermissions: nil, + } } // Create, assign, and use a custom role @@ -50,31 +53,43 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - role, err := owner.PatchRole(ctx, templateAdminCustom) + role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID)) require.NoError(t, err, "upsert role") // Assign the custom template admin role - tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) // Assert the role exists - roleNamesF := func(role codersdk.SlimRole) string { return role.Name } - require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name) + // TODO: At present user roles are not returned by the user endpoints. + // Changing this might mess up the UI in how it renders the roles on the + // users page. When the users endpoint is updated, this should be uncommented. + // 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) // Verify the role exists in the list - allRoles, err := tmplAdmin.ListSiteRoles(ctx) + allRoles, err := tmplAdmin.ListOrganizationRoles(ctx, first.OrganizationID) require.NoError(t, err) + var foundRole codersdk.AssignableRoles require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { - return selected.Name == role.Name - }), "role missing from site role list") + if selected.Name == role.Name { + foundRole = selected + return true + } + return false + }), "role missing from org role list") + + require.Len(t, foundRole.SitePermissions, 0) + require.Len(t, foundRole.OrganizationPermissions, 7) + require.Len(t, foundRole.UserPermissions, 0) }) // Revoked licenses cannot modify/create custom roles, but they can // use the existing roles. - t.Run("Revoked License", func(t *testing.T) { + t.Run("RevokedLicense", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} @@ -92,7 +107,7 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - role, err := owner.PatchRole(ctx, templateAdminCustom) + role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID)) require.NoError(t, err, "upsert role") // Remove the license to block enterprise functionality @@ -105,11 +120,11 @@ func TestCustomRole(t *testing.T) { } // Verify functionality is lost - _, err = owner.PatchRole(ctx, templateAdminCustom) - require.ErrorContains(t, err, "Custom roles is an Enterprise feature", "upsert role") + _, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID)) + require.ErrorContains(t, err, "roles are not enabled") // Assign the custom template admin role - tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) // Try to create a template version, eg using the custom role coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) @@ -133,26 +148,24 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - role, err := owner.PatchRole(ctx, templateAdminCustom) + role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID)) require.NoError(t, err, "upsert role") // Assign the custom template admin role - tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName()) // 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, - }) + newRole := templateAdminCustom(first.OrganizationID) + // These are all left nil, which sets the custom role to have 0 + // permissions. Omitting this does not "inherit" what already + // exists. + newRole.SitePermissions = nil + newRole.OrganizationPermissions = nil + newRole.UserPermissions = nil + _, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, newRole) require.NoError(t, err, "upsert role with override") // The role should no longer have template perms @@ -172,7 +185,7 @@ func TestCustomRole(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} - owner, _ := coderdenttest.New(t, &coderdenttest.Options{ + owner, first := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dv, }, @@ -186,18 +199,103 @@ func TestCustomRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) //nolint:gocritic // owner is required for this - _, err := owner.PatchRole(ctx, codersdk.Role{ - Name: "Bad_Name", // No underscores allowed - 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}, - }), + _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, codersdk.Role{ + Name: "Bad_Name", // No underscores allowed + DisplayName: "Testing Purposes", + SitePermissions: nil, OrganizationPermissions: nil, UserPermissions: nil, }) require.ErrorContains(t, err, "Validation") }) + + t.Run("MismatchedOrganizations", 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 + _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(uuid.New())) + require.ErrorContains(t, err, "does not match") + }) + + // Attempt to add site & user permissions, which is not allowed + t.Run("ExcessPermissions", 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) + + siteRole := templateAdminCustom(first.OrganizationID) + siteRole.SitePermissions = []codersdk.Permission{ + { + ResourceType: codersdk.ResourceWorkspace, + Action: codersdk.ActionRead, + }, + } + + //nolint:gocritic // owner is required for this + _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, siteRole) + require.ErrorContains(t, err, "site wide permissions") + + userRole := templateAdminCustom(first.OrganizationID) + userRole.UserPermissions = []codersdk.Permission{ + { + ResourceType: codersdk.ResourceWorkspace, + Action: codersdk.ActionRead, + }, + } + + //nolint:gocritic // owner is required for this + _, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, userRole) + require.ErrorContains(t, err, "not allowed to assign user permissions") + }) + + t.Run("InvalidUUID", 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) + + newRole := templateAdminCustom(first.OrganizationID) + newRole.OrganizationID = "0000" // This is not a valid uuid + + //nolint:gocritic // owner is required for this + _, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, newRole) + require.ErrorContains(t, err, "Invalid request") + }) } diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index a29aa1836557d..935eeb8f6e689 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -14,31 +14,6 @@ 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/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5d4d148758f36..171f6744680cb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -980,7 +980,7 @@ export interface Role { readonly organization_id: string; readonly display_name: string; readonly site_permissions: readonly Permission[]; - readonly organization_permissions: Record; + readonly organization_permissions: readonly Permission[]; readonly user_permissions: readonly Permission[]; } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1fbb18aa86a07..5ff5fa6cd84c7 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -233,7 +233,7 @@ export const MockOwnerRole: TypesGen.Role = { name: "owner", display_name: "Owner", site_permissions: [], - organization_permissions: {}, + organization_permissions: [], user_permissions: [], organization_id: "", }; @@ -242,7 +242,7 @@ export const MockUserAdminRole: TypesGen.Role = { name: "user_admin", display_name: "User Admin", site_permissions: [], - organization_permissions: {}, + organization_permissions: [], user_permissions: [], organization_id: "", }; @@ -251,7 +251,7 @@ export const MockTemplateAdminRole: TypesGen.Role = { name: "template_admin", display_name: "Template Admin", site_permissions: [], - organization_permissions: {}, + organization_permissions: [], user_permissions: [], organization_id: "", }; @@ -265,7 +265,7 @@ export const MockAuditorRole: TypesGen.Role = { name: "auditor", display_name: "Auditor", site_permissions: [], - organization_permissions: {}, + organization_permissions: [], user_permissions: [], organization_id: "", };