Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(coderd): validate chat workspace binding as the owner
A chat connects to its workspace as the owner, so the SSH check must run
with the owner's roles rather than the caller's when an administrator
creates a chat for someone else. The owner resolver now returns an error
through httperror instead of a status and response pair.
  • Loading branch information
ibetitsmike committed Sep 18, 2026
commit 363d45281d82969988321a3fd8d8b77e4d3baa0f
62 changes: 33 additions & 29 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -1202,20 +1202,20 @@ func invalidChatMCPServerIDsResponse(ids []uuid.UUID) codersdk.Response {
// @Router /api/v2/chats [post]
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
api.createChat(rw, r, func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, int, *codersdk.Response) {
api.createChat(rw, r, func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
isMember, err := httpmw.UserAuthorization(ctx).HasOrganizationMembership(organizationID)
if err != nil {
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
return uuid.Nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: xerrors.Errorf("check organization membership: %w", err).Error(),
}
})
}
if !isMember {
return uuid.Nil, http.StatusForbidden, &codersdk.Response{
return uuid.Nil, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "You are not a member of the specified organization.",
}
})
}
return apiKey.UserID, 0, nil
return apiKey.UserID, nil
})
}

Expand All @@ -1232,27 +1232,22 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
// @Router /api/v2/users/{user}/chats [post]
func (api *API) postUserChats(rw http.ResponseWriter, r *http.Request) {
mems := httpmw.OrganizationMembersParam(r)
api.createChat(rw, r, func(_ context.Context, organizationID uuid.UUID) (uuid.UUID, int, *codersdk.Response) {
api.createChat(rw, r, func(_ context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
// The memberships are already limited to what the caller may read,
// so a missing match stays a vague 404 like postUserWorkspaces.
idx := slices.IndexFunc(mems.Memberships, func(member httpmw.OrganizationMember) bool {
return member.OrganizationID == organizationID
})
if idx == -1 {
return uuid.Nil, http.StatusNotFound, &httpapi.ResourceNotFoundResponse
return uuid.Nil, httperror.ErrResourceNotFound
}
return mems.Memberships[idx].UserID, 0, nil
return mems.Memberships[idx].UserID, nil
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
})
}

// createChatOwnerResolver returns the owner of a new chat once the requested
// organization is known, or the response that rejects the request.
type createChatOwnerResolver func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, int, *codersdk.Response)

// createChat is the shared body of the chat creation endpoints. It parses
// the request, resolves the owner, authorizes the caller, and creates the
// chat.
func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner createChatOwnerResolver) {
// createChat backs both chat creation endpoints. resolveOwner runs after the
// body is parsed because the owner depends on req.OrganizationID.
func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)

Expand Down Expand Up @@ -1281,9 +1276,9 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner
})
return
}
ownerID, ownerStatus, ownerError := resolveOwner(ctx, req.OrganizationID)
if ownerError != nil {
httpapi.Write(ctx, rw, ownerStatus, *ownerError)
ownerID, err := resolveOwner(ctx, req.OrganizationID)
if err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
}
// NOTE: This authorize check is intentionally placed after request
Expand All @@ -1299,9 +1294,20 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner
// acting as that user. Org-scoped chat permissions are not enough:
// require the same authority the token endpoint demands to mint a
// session for that user.
if ownerID != apiKey.UserID && !api.Authorize(r, policy.ActionCreate, rbac.ResourceApiKey.WithOwner(ownerID.String())) {
httpapi.Forbidden(rw)
return
ownerCtx := ctx
if ownerID != apiKey.UserID {
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceApiKey.WithOwner(ownerID.String())) {
httpapi.Forbidden(rw)
return
}
owner, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// The workspace must be usable by the owner, who is the one the
// chat will connect as, not merely visible to the caller.
ownerCtx = dbauthz.As(ctx, owner)
}

contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
Expand All @@ -1310,7 +1316,7 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner
return
}

workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ctx, r, req)
workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ownerCtx, req)
if validationError != nil {
httpapi.Write(ctx, rw, validationStatus, *validationError)
return
Expand Down Expand Up @@ -2495,7 +2501,7 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
if *req.WorkspaceID != uuid.Nil {
var status int
var resp *codersdk.Response
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
Expand Down Expand Up @@ -4216,7 +4222,6 @@ type createChatWorkspaceSelection struct {

func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
workspaceID *uuid.UUID,
) (
uuid.NullUUID,
Expand Down Expand Up @@ -4245,7 +4250,7 @@ func (api *API) validateChatWorkspaceSelection(
UUID: workspace.ID,
Valid: true,
}
if !api.Authorize(r, policy.ActionSSH, workspace) {
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionSSH, workspace) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
Expand All @@ -4256,15 +4261,14 @@ func (api *API) validateChatWorkspaceSelection(

func (api *API) validateCreateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
req codersdk.CreateChatRequest,
) (
createChatWorkspaceSelection,
int,
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
return selection, status, resp
}
Expand Down
51 changes: 46 additions & 5 deletions coderd/exp_chats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1831,11 +1831,9 @@ func TestPostUserChats(t *testing.T) {
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)

// The member owns the chat while the prompt stays attributed to the
// admin who submitted it.
fetched, err := memberClient.GetChat(ctx, chat.ID)
// The member can read the chat; the prompt stays attributed to the admin.
_, err = memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, member.ID, fetched.OwnerID)
messages, err := db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
})
Expand Down Expand Up @@ -1875,7 +1873,7 @@ func TestPostUserChats(t *testing.T) {
require.Equal(t, member.ID, chat.OwnerID)
})

t.Run("MeMatchesPostChats", func(t *testing.T) {
t.Run("MeCreatesOwnChat", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
Expand All @@ -1890,6 +1888,49 @@ func TestPostUserChats(t *testing.T) {
require.Equal(t, member.ID, chat.OwnerID)
})

t.Run("WorkspaceOwnedByMember", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
}).WithAgent().Do()

req := helloRequest(firstUser.OrganizationID)
req.WorkspaceID = &workspaceBuild.Workspace.ID
chat, err := client.CreateUserChat(ctx, member.Username, req)
require.NoError(t, err)
require.NotNil(t, chat.WorkspaceID)
require.Equal(t, workspaceBuild.Workspace.ID, *chat.WorkspaceID)
})

t.Run("WorkspaceNotAccessibleToOwner", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()

// The admin can reach their own workspace, but the chat connects
// as the member, who cannot.
req := helloRequest(firstUser.OrganizationID)
req.WorkspaceID = &workspaceBuild.Workspace.ID
_, err := client.CreateUserChat(ctx, member.Username, req)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Workspace not found or you do not have access to this resource", sdkErr.Message)
})

t.Run("OrgAdminForbidden", func(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 1 addition & 1 deletion coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ This endpoint uses `Create(initialMessages)`:

No other input states are supported.

TODO: document `POST /api/v2/users/{user}/chats`, which creates a chat owned by another user through the same transition. The caller must hold org-scoped `chat:create` for that owner and the authority to create API keys for that user, because processing runs with the owner's credentials. The initial user message is attributed to the caller rather than the owner.
TODO: document `POST /api/v2/users/{user}/chats`, which creates a chat owned by another user through the same transition. The caller must hold org-scoped `chat:create` for that owner and the authority to create API keys for that user, because processing runs with the owner's credentials; the workspace binding is validated as the owner for the same reason. The initial user message is attributed to the caller rather than the owner.

### `PATCH /api/experimental/chats/{chat}`

Expand Down
3 changes: 1 addition & 2 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1131,8 +1131,7 @@ var (
type CreateOptions struct {
OrganizationID uuid.UUID
OwnerID uuid.UUID
// CreatedBy attributes the initial user message. It falls back to
// OwnerID when unset.
// CreatedBy attributes the initial user message; defaults to OwnerID.
CreatedBy uuid.UUID
WorkspaceID uuid.NullUUID
BuildID uuid.NullUUID
Expand Down
Loading