From e8d601680713b79cdcfaf0109f39d517afe9013e Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 19 Feb 2026 10:04:53 -0800 Subject: [PATCH] fix: allow users with workspace:create for any owner to list users (#21947) ## Summary Custom roles that can create workspaces on behalf of other users need to be able to list users to populate the owner dropdown in the workspace creation UI. Previously, this required a separate `user:read` permission, causing the dropdown to fail for custom roles. ## Changes - Modified `GetUsers` in `dbauthz` to check if the user can create workspaces for any owner (`workspace:create` with `owner_id: *`) - If the user has this permission, they can list all users without needing explicit `user:read` permission - Added tests to verify the new behavior ## Testing - Updated mock tests to assert the new authorization check - Added integration tests for both positive and negative cases Fixes #18203 --- coderd/apidoc/docs.go | 63 +++++++++++++++++++ coderd/apidoc/swagger.json | 59 +++++++++++++++++ coderd/coderd.go | 5 +- coderd/rbac/roles_test.go | 10 +++ coderd/workspaces.go | 45 +++++++++++++ coderd/workspaces_test.go | 48 ++++++++++++++ codersdk/workspaces.go | 16 +++++ docs/reference/api/workspaces.md | 58 +++++++++++++++++ site/src/api/api.ts | 22 +++++++ site/src/api/queries/users.ts | 13 ++++ .../UserAutocomplete/UserAutocomplete.tsx | 45 +++++++++++-- .../CreateWorkspacePage.tsx | 5 +- .../CreateWorkspacePageView.tsx | 15 ++--- 13 files changed, 388 insertions(+), 16 deletions(-) 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) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|----------------|--------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string(uri) | false | | | +| `» id` | string(uuid) | true | | | +| `» name` | string | false | | | +| `» username` | string | true | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace metadata by user and workspace name ### Code samples diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 203ba1855a..48ab1749ad 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -569,6 +569,28 @@ class ApiMethods { return response.data; }; + /** + * Get users for workspace owner selection. Requires + * permission to create workspaces for other users in the + * organization. Returns minimal user data (no email, roles, + * etc.). + */ + getWorkspaceAvailableUsers = async ( + organizationId: string, + options: TypesGen.UsersRequest, + signal?: AbortSignal, + ): Promise => { + const url = getURLWithSearchParams( + `/api/v2/organizations/${organizationId}/members/me/workspaces/available-users`, + options, + ); + const response = await this.axios.get( + url.toString(), + { signal }, + ); + return response.data; + }; + createOrganization = async (params: TypesGen.CreateOrganizationRequest) => { const response = await this.axios.post( "/api/v2/organizations", diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index e6ec7e3364..9287bf3f23 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -3,6 +3,7 @@ import type { AuthorizationRequest, GenerateAPIKeyResponse, GetUsersResponse, + MinimalUser, RequestOneTimePasscodeRequest, UpdateUserAppearanceSettingsRequest, UpdateUserPasswordRequest, @@ -58,6 +59,18 @@ export const users = (req: UsersRequest): UseQueryOptions => { }; }; +export const workspaceAvailableUsers = ( + organizationId: string, + req: UsersRequest, +): UseQueryOptions => { + return { + queryKey: ["workspaceAvailableUsers", organizationId, req], + queryFn: ({ signal }) => + API.getWorkspaceAvailableUsers(organizationId, req, signal), + gcTime: 5 * 1000 * 60, + }; +}; + export const updatePassword = () => { return { mutationFn: ({ diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index 21a7fce03a..0db917b47a 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -4,8 +4,12 @@ import CircularProgress from "@mui/material/CircularProgress"; import TextField from "@mui/material/TextField"; import { getErrorMessage } from "api/errors"; import { organizationMembers } from "api/queries/organizations"; -import { users } from "api/queries/users"; -import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; +import { users, workspaceAvailableUsers } from "api/queries/users"; +import type { + MinimalUser, + OrganizationMemberWithUserData, + User, +} from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { useDebouncedFunction } from "hooks/debounce"; @@ -21,7 +25,7 @@ import { prepareQuery } from "utils/filters"; // The common properties between users and org members that we need. type SelectedUser = { avatar_url?: string; - email: string; + email?: string; username: string; }; @@ -85,6 +89,35 @@ export const MemberAutocomplete: FC = ({ ); }; +type WorkspaceUserAutocompleteProps = CommonAutocompleteProps & { + organizationId: string; +}; + +export const WorkspaceUserAutocomplete: FC = ({ + organizationId, + ...props +}) => { + const [filter, setFilter] = useState(); + + const availableUsersQuery = useQuery({ + ...workspaceAvailableUsers(organizationId, { + q: prepareQuery(encodeURI(filter ?? "")), + limit: 25, + }), + enabled: filter !== undefined, + placeholderData: keepPreviousData, + }); + return ( + + error={availableUsersQuery.error} + isFetching={availableUsersQuery.isFetching} + setFilter={setFilter} + users={availableUsersQuery.data} + {...props} + /> + ); +}; + type InnerAutocompleteProps = CommonAutocompleteProps & { /** The error is null if not loaded or no error. */ @@ -131,10 +164,10 @@ const InnerAutocomplete = ({ data-testid="user-autocomplete" open={open} isOptionEqualToValue={(a, b) => a.username === b.username} - getOptionLabel={(option) => option.email} + getOptionLabel={(option) => option.email ?? option.username} onOpen={() => { setOpen(true); - setFilter(value?.email ?? ""); + setFilter(value?.email ?? value?.username ?? ""); }} onClose={() => { setOpen(false); @@ -159,7 +192,7 @@ const InnerAutocomplete = ({ fullWidth size={size} label={label} - placeholder="User email or username" + placeholder="Username or email" css={{ "&:not(:has(label))": { margin: 0, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index b3fbb46b6c..e276fd7293 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -11,6 +11,7 @@ import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { DynamicParametersRequest, DynamicParametersResponse, + MinimalUser, PreviewParameter, Workspace, } from "api/typesGenerated"; @@ -63,8 +64,8 @@ const CreateWorkspacePage: FC = () => { const [autoCreateConsented, setAutoCreateConsented] = useState(false); const [autoCreateError, setAutoCreateError] = useState(null); - const defaultOwner = me; - const [owner, setOwner] = useState(defaultOwner); + const defaultOwner: MinimalUser = me; + const [owner, setOwner] = useState(defaultOwner); const queryClient = useQueryClient(); const autoCreateWorkspaceMutation = useMutation( diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 6474c4c1b4..1732c0a7c4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -22,7 +22,7 @@ import { TooltipContent, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { WorkspaceUserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { useDebouncedFunction } from "hooks/debounce"; import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; @@ -57,7 +57,7 @@ interface CreateWorkspacePageViewProps { canUpdateTemplate?: boolean; creatingWorkspace: boolean; defaultName?: string | null; - defaultOwner: TypesGen.User; + defaultOwner: TypesGen.MinimalUser; diagnostics: readonly FriendlyDiagnostic[]; disabledParams?: string[]; error: unknown; @@ -74,13 +74,13 @@ interface CreateWorkspacePageViewProps { onCancel: () => void; onSubmit: ( req: TypesGen.CreateWorkspaceRequest, - owner: TypesGen.User, + owner: TypesGen.MinimalUser, ) => void; resetMutation: () => void; sendMessage: (message: Record, ownerId?: string) => void; startPollingExternalAuth: () => void; - owner: TypesGen.User; - setOwner: (user: TypesGen.User) => void; + owner: TypesGen.MinimalUser; + setOwner: (user: TypesGen.MinimalUser) => void; } export const CreateWorkspacePageView: FC = ({ @@ -313,7 +313,7 @@ export const CreateWorkspacePageView: FC = ({ sendDynamicParamsRequest, ]); - const handleOwnerChange = (user: TypesGen.User) => { + const handleOwnerChange = (user: TypesGen.MinimalUser) => { setOwner(user); sendDynamicParamsRequest([], user.id); }; @@ -503,7 +503,8 @@ export const CreateWorkspacePageView: FC = ({ - { handleOwnerChange(user ?? defaultOwner);