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
This commit is contained in:
Garrett Delfosse
2026-02-19 13:04:53 -05:00
committed by GitHub
parent 911d734df9
commit e8d6016807
13 changed files with 388 additions and 16 deletions
+63
View File
@@ -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": [
+59
View File
@@ -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": [
+4 -1
View File
@@ -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)
})
})
})
})
+10
View File
@@ -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.
+45
View File
@@ -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)
}
+48
View File
@@ -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()
+16
View File
@@ -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)
}
+58
View File
@@ -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) |
<h3 id="get-users-available-for-workspace-creation-responseschema">Response Schema</h3>
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
+22
View File
@@ -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<TypesGen.MinimalUser[]> => {
const url = getURLWithSearchParams(
`/api/v2/organizations/${organizationId}/members/me/workspaces/available-users`,
options,
);
const response = await this.axios.get<TypesGen.MinimalUser[]>(
url.toString(),
{ signal },
);
return response.data;
};
createOrganization = async (params: TypesGen.CreateOrganizationRequest) => {
const response = await this.axios.post<TypesGen.Organization>(
"/api/v2/organizations",
+13
View File
@@ -3,6 +3,7 @@ import type {
AuthorizationRequest,
GenerateAPIKeyResponse,
GetUsersResponse,
MinimalUser,
RequestOneTimePasscodeRequest,
UpdateUserAppearanceSettingsRequest,
UpdateUserPasswordRequest,
@@ -58,6 +59,18 @@ export const users = (req: UsersRequest): UseQueryOptions<GetUsersResponse> => {
};
};
export const workspaceAvailableUsers = (
organizationId: string,
req: UsersRequest,
): UseQueryOptions<MinimalUser[]> => {
return {
queryKey: ["workspaceAvailableUsers", organizationId, req],
queryFn: ({ signal }) =>
API.getWorkspaceAvailableUsers(organizationId, req, signal),
gcTime: 5 * 1000 * 60,
};
};
export const updatePassword = () => {
return {
mutationFn: ({
@@ -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<MemberAutocompleteProps> = ({
);
};
type WorkspaceUserAutocompleteProps = CommonAutocompleteProps<MinimalUser> & {
organizationId: string;
};
export const WorkspaceUserAutocomplete: FC<WorkspaceUserAutocompleteProps> = ({
organizationId,
...props
}) => {
const [filter, setFilter] = useState<string>();
const availableUsersQuery = useQuery({
...workspaceAvailableUsers(organizationId, {
q: prepareQuery(encodeURI(filter ?? "")),
limit: 25,
}),
enabled: filter !== undefined,
placeholderData: keepPreviousData,
});
return (
<InnerAutocomplete<MinimalUser>
error={availableUsersQuery.error}
isFetching={availableUsersQuery.isFetching}
setFilter={setFilter}
users={availableUsersQuery.data}
{...props}
/>
);
};
type InnerAutocompleteProps<T extends SelectedUser> =
CommonAutocompleteProps<T> & {
/** The error is null if not loaded or no error. */
@@ -131,10 +164,10 @@ const InnerAutocomplete = <T extends SelectedUser>({
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 = <T extends SelectedUser>({
fullWidth
size={size}
label={label}
placeholder="User email or username"
placeholder="Username or email"
css={{
"&:not(:has(label))": {
margin: 0,
@@ -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<ApiErrorResponse | null>(null);
const defaultOwner = me;
const [owner, setOwner] = useState(defaultOwner);
const defaultOwner: MinimalUser = me;
const [owner, setOwner] = useState<MinimalUser>(defaultOwner);
const queryClient = useQueryClient();
const autoCreateWorkspaceMutation = useMutation(
@@ -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<string, string>, 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<CreateWorkspacePageViewProps> = ({
@@ -313,7 +313,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
sendDynamicParamsRequest,
]);
const handleOwnerChange = (user: TypesGen.User) => {
const handleOwnerChange = (user: TypesGen.MinimalUser) => {
setOwner(user);
sendDynamicParamsRequest([], user.id);
};
@@ -503,7 +503,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
Owner
</Label>
<UserAutocomplete
<WorkspaceUserAutocomplete
organizationId={template.organization_id}
value={owner}
onChange={(user) => {
handleOwnerChange(user ?? defaultOwner);