diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6e76d28383..29e7788106 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3745,6 +3745,69 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}/workspaces/available-users": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Get users available for workspace creation", + "operationId": "get-users-available-for-workspace-creation", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset for pagination", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.MinimalUser" + } + } + } + } + } + }, "/organizations/{organization}/paginated-members": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b90ad878e1..350116ecd9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3296,6 +3296,65 @@ } } }, + "/organizations/{organization}/members/{user}/workspaces/available-users": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Get users available for workspace creation", + "operationId": "get-users-available-for-workspace-creation", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Limit results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset for pagination", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.MinimalUser" + } + } + } + } + } + }, "/organizations/{organization}/paginated-members": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 24a9e7c531..3594956bbe 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1232,7 +1232,10 @@ func New(options *Options) *API { r.Get("/", api.organizationMember) r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) - r.Post("/workspaces", api.postWorkspacesByOrganization) + r.Route("/workspaces", func(r chi.Router) { + r.Post("/", api.postWorkspacesByOrganization) + r.Get("/available-users", api.workspaceAvailableUsers) + }) }) }) }) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index f08930054d..0cf67f3cf0 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -265,6 +265,16 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, }, }, + { + Name: "CreateWorkspaceForMembers", + // When creating the WithID won't be set, but it does not change the result. + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(policy.WildcardSymbol), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin}, + false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, templateAdmin, orgTemplateAdmin}, + }, + }, { Name: "MyWorkspaceInOrgExecution", // When creating the WithID won't be set, but it does not change the result. diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c4461fefd0..1077cafe6a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2952,3 +2952,48 @@ func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole { return codersdk.WorkspaceRoleDeleted } + +// @Summary Get users available for workspace creation +// @ID get-users-available-for-workspace-creation +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param organization path string true "Organization ID" format(uuid) +// @Param user path string true "User ID, name, or me" +// @Param q query string false "Search query" +// @Param limit query int false "Limit results" +// @Param offset query int false "Offset for pagination" +// @Success 200 {array} codersdk.MinimalUser +// @Router /organizations/{organization}/members/{user}/workspaces/available-users [get] +func (api *API) workspaceAvailableUsers(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + + // This endpoint requires the user to be able to create workspaces for other + // users in this organization. We check if they can create a workspace with + // a wildcard owner. + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(policy.WildcardSymbol)) { + httpapi.Forbidden(rw) + return + } + + // Use system context to list all users. The authorization check above + // ensures only users who can create workspaces for others can access this. + //nolint:gocritic // System context needed to list users for workspace owner selection. + users, _, ok := api.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx))) + if !ok { + return + } + + minimalUsers := make([]codersdk.MinimalUser, 0, len(users)) + for _, user := range users { + minimalUsers = append(minimalUsers, codersdk.MinimalUser{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + AvatarURL: user.AvatarURL, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, minimalUsers) +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5a6626cdc3..e09ea3a29b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5625,6 +5625,54 @@ func TestWorkspaceSharingDisabled(t *testing.T) { }) } +func TestWorkspaceAvailableUsers(t *testing.T) { + t.Parallel() + + t.Run("OrgAdminCanListUsers", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create an org admin and additional users + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + _, user1 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, user2 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Org admin should be able to list available users + users, err := orgAdminClient.WorkspaceAvailableUsers(ctx, owner.OrganizationID, "me") + require.NoError(t, err) + require.GreaterOrEqual(t, len(users), 4) // owner + orgAdmin + 2 users + + // Verify the users we created are in the list + usernames := make([]string, 0, len(users)) + for _, u := range users { + usernames = append(usernames, u.Username) + } + require.Contains(t, usernames, user1.Username) + require.Contains(t, usernames, user2.Username) + }) + + t.Run("MemberCannotListUsers", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create a regular member + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Regular member should not be able to list available users + _, err := memberClient.WorkspaceAvailableUsers(ctx, owner.OrganizationID, "me") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) +} + func TestWorkspaceCreateWithImplicitPreset(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 4a93014c79..bf588722b8 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -787,3 +787,19 @@ func (c *Client) WorkspaceExternalAgentCredentials(ctx context.Context, workspac var credentials ExternalAgentCredentials return credentials, json.NewDecoder(res.Body).Decode(&credentials) } + +// WorkspaceAvailableUsers returns users available for workspace creation. +// This is used to populate the owner dropdown when creating workspaces for +// other users. +func (c *Client) WorkspaceAvailableUsers(ctx context.Context, organizationID uuid.UUID, userID string) ([]MinimalUser, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s/workspaces/available-users", organizationID, userID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var users []MinimalUser + return users, json.NewDecoder(res.Body).Decode(&users) +} diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 43bf00bc43..9d54ee018d 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -331,6 +331,64 @@ of the template will be used. To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get users available for workspace creation + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/workspaces/available-users \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members/{user}/workspaces/available-users` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|-----------------------| +| `organization` | path | string(uuid) | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | +| `q` | query | string | false | Search query | +| `limit` | query | integer | false | Limit results | +| `offset` | query | integer | false | Offset for pagination | + +### Example responses + +> 200 Response + +```json +[ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "username": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.MinimalUser](schemas.md#codersdkminimaluser) | + +