From 420ba025cac7b6046ce0306f96964837d86b57ec Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Thu, 20 Aug 2026 12:12:12 +0000 Subject: [PATCH] fix: gate group settings page and fix unauthorized group patch Updating a group (e.g. its budget) as a user without group:update returned a 400 "Failed to add or remove non-existent group member" because the authorization failure was misclassified. Return the standard vague 404 for unauthorized errors so the response is accurate and does not leak resource existence, and use a generic message for the remaining 400 branch since it covers various transaction failures. Also gate the group settings page on group:update. The settings tab is already hidden without the permission, but the page was reachable by direct URL and rendered an editable form that always failed on save. It now shows the standard permission dialog. --- enterprise/coderd/groups.go | 8 +++- enterprise/coderd/groups_test.go | 46 +++++++++++++++++++ .../pages/GroupsPage/GroupPage.stories.tsx | 42 +++++++++++++++++ .../pages/GroupsPage/GroupSettingsPage.tsx | 11 ++++- 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 24589dd5cd8fd..8103b4018ca84 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -285,9 +285,15 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { }) return } + // Unauthorized errors return a 404 to not leak information about the + // existence of resources. + if httpapi.IsUnauthorizedError(err) { + httpapi.ResourceNotFound(rw) + return + } if httpapi.Is404Error(err) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to add or remove non-existent group member", + Message: "Failed to update group.", Detail: err.Error(), }) return diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index ae1dee58350a6..946bc30cd551d 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -420,6 +420,52 @@ func TestPatchGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + // A group member can read the group but must not be able to update it. + // The update must be rejected as an authorization failure that returns a + // 404 to not leak resource existence. + t.Run("MemberWithoutUpdatePermission", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + memberClient, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + // Make the actor a member of the group so it can read the group but + // still lacks group:update. + _, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{member.ID.String()}, + }) + require.NoError(t, err) + + // Updating the group budget must fail as unauthorized (404). + _, err = memberClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(20), + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + + // Adding a member must also fail as unauthorized (404). + _, err = memberClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user.UserID.String()}, + }) + require.Error(t, err) + cerr, ok = codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) + t.Run("MalformedUUID", func(t *testing.T) { t.Parallel() diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 6d8df2f5641ba..023d03f27773b 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -43,6 +43,7 @@ import { } from "#/testHelpers/storybook"; import GroupMembersPage from "./GroupMembersPage"; import GroupPage from "./GroupPage"; +import GroupSettingsPage from "./GroupSettingsPage"; const meta: Meta = { title: "pages/OrganizationGroupsPage/GroupPage", @@ -208,6 +209,47 @@ export const NoUpdatePermission: Story = { }, }; +/** + * The settings page is reachable by direct URL even when the settings tab is + * hidden. Without group:update, it must render the permission dialog instead + * of the editable form. + */ +export const SettingsWithoutUpdatePermission: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { + pathParams: { + organization: MockDefaultOrganization.name, + groupName: MockGroupWithoutMembers.name, + }, + }, + routing: reactRouterOutlet( + { path: "/organizations/:organization/groups/:groupName/settings" }, + , + ), + }), + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: MockGroup.members, + count: MockGroup.members.length, + }), + permissionsQuery({ canUpdateGroup: false }), + ], + }, + play: async ({ canvasElement }) => { + const body = within(document.body); + await expect( + await body.findByText("You don't have permission to view this page"), + ).toBeInTheDocument(); + // The editable budget/name form is never rendered. + const canvas = within(canvasElement); + expect( + canvas.queryByRole("button", { name: "Save" }), + ).not.toBeInTheDocument(); + }, +}; + export const EveryoneGroup: Story = { parameters: { queries: [ diff --git a/site/src/pages/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/GroupsPage/GroupSettingsPage.tsx index e7f5d29359a84..25186e10daf6e 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPage.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPage.tsx @@ -11,6 +11,7 @@ import { import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Spinner } from "#/components/Spinner/Spinner"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { dollarsToMicros, microsToDollars } from "#/utils/currency"; import type { GroupPageOutletContext } from "./GroupPage"; import GroupSettingsPageView from "./GroupSettingsPageView"; @@ -23,20 +24,26 @@ const GroupSettingsPage: FC = () => { organization?: string; groupName: string; }; - const { group: groupData } = useOutletContext(); + const { group: groupData, permissions } = + useOutletContext(); const queryClient = useQueryClient(); const patchGroupMutation = useMutation(patchGroup(queryClient, organization)); const navigate = useNavigate(); const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); + const canUpdateGroup = permissions.canUpdateGroup; const budgetQuery = useQuery({ ...groupAIBudget(groupData.id), - enabled: aibridgeVisible, + enabled: aibridgeVisible && canUpdateGroup, }); const saveBudgetMutation = useMutation( saveGroupAIBudget(queryClient, groupData.id), ); + if (!canUpdateGroup) { + return ; + } + if (aibridgeVisible && budgetQuery.isLoading) { return (