From dd6855a9be2ec19e4cc6cce0e1667d4e9a1ad27f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:33:06 +0000 Subject: [PATCH] fix(coderd): restrict chat goal updates to owners --- coderd/exp_chats.go | 10 +++++++ coderd/exp_chats_test.go | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index f1ce70bead..01a023d36e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2245,6 +2245,16 @@ func (api *API) patchChatGoal(rw http.ResponseWriter, r *http.Request) { return } + // Only the chat owner may mutate goals. Shared readers with the + // owner role pass the RBAC check above, but the goal controls the + // chat owner's future agent behavior. + if apiKey.UserID != chat.OwnerID { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Only the chat owner may update goals.", + }) + return + } + if api.chatDaemon == nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Chat processor is unavailable.", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index ba0a63996e..7b4ff8b08d 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -413,6 +413,68 @@ func TestChatGoalAPI(t *testing.T) { require.Len(t, sentAsGoal, 2) } +func TestPatchChatGoalRequiresOwnerForSharedSiteOwner(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + ownerClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, ownerClient.Client) + createChatModelConfig(t, ownerClient) + + sharedOwnerRaw, sharedOwner := coderdtest.CreateAnotherUser( + t, + ownerClient.Client, + firstUser.OrganizationID, + rbac.RoleOwner(), + rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), + ) + sharedOwnerClient := codersdk.NewExperimentalClient(sharedOwnerRaw) + + chat, err := ownerClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "start with an owner goal", + }}, + GoalMutation: &codersdk.ChatGoalMutation{ + Action: codersdk.ChatGoalMutationActionSet, + Objective: "protect goal updates", + }, + }) + require.NoError(t, err) + require.NotNil(t, chat.Goal) + + err = db.UpdateChatACLByID(dbauthz.As(ctx, rbac.Subject{ + ID: firstUser.UserID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Scope: rbac.ScopeAll, + }), database.UpdateChatACLByIDParams{ + ID: chat.ID, + UserACL: database.ChatACL{ + sharedOwner.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}}, + }, + GroupACL: database.ChatACL{}, + }) + require.NoError(t, err) + + gotGoal, err := sharedOwnerClient.GetChatGoal(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, gotGoal.Goal) + require.Equal(t, chat.Goal.ID, gotGoal.Goal.ID) + + _, err = sharedOwnerClient.UpdateChatGoal(ctx, chat.ID, codersdk.ChatGoalMutation{ + Action: codersdk.ChatGoalMutationActionPause, + GoalID: &chat.Goal.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusForbidden) + require.Contains(t, sdkErr.Message, "Only the chat owner") + + current, err := ownerClient.GetChatGoal(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, current.Goal) + require.Equal(t, codersdk.ChatGoalStatusActive, current.Goal.Status) +} + func TestPostChats(t *testing.T) { t.Parallel()