diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 24589dd5cd8..8103b4018ca 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 ae1dee58350..946bc30cd55 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 6d8df2f5641..023d03f2777 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 e7f5d29359a..25186e10daf 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 (