From aec697f5b94d977d86b8b53dd809ec918aad351f Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:08:51 +0530 Subject: [PATCH] fix: role escalation in org settings (#4901) Co-authored-by: pandeymangg --- .../web/modules/ee/role-management/actions.ts | 26 + .../components/add-member-role.tsx | 34 +- .../components/add-member.test.tsx | 104 ++++ .../components/edit-membership-role.test.tsx | 93 ++++ .../components/edit-membership-role.tsx | 17 +- .../tests/__mocks__/actions.mock.ts | 107 ++++ .../ee/role-management/tests/actions.test.ts | 256 ++++++++++ .../organization/settings/teams/actions.ts | 13 +- .../organization-actions.test.tsx | 242 ++++++++++ .../edit-memberships/organization-actions.tsx | 9 +- .../invite-member/individual-invite-tab.tsx | 5 +- .../invite-member/invite-member-modal.tsx | 3 + .../teams/components/members-view.tsx | 6 +- .../teams/tests/__mocks__/actions.mock.ts | 207 ++++++++ .../settings/teams/tests/actions.test.ts | 457 ++++++++++++++++++ .../web/modules/ui/components/badge/index.tsx | 4 +- apps/web/vite.config.mts | 5 +- .../core-features/user-management.mdx | 34 +- .../general-features/hidden-fields.mdx | 26 +- packages/lib/messages/de-DE.json | 1 - packages/lib/messages/en-US.json | 1 - packages/lib/messages/fr-FR.json | 1 - packages/lib/messages/pt-BR.json | 1 - packages/lib/messages/pt-PT.json | 1 - packages/lib/messages/zh-Hant-TW.json | 1 - sonar-project.properties | 2 +- 26 files changed, 1601 insertions(+), 55 deletions(-) create mode 100644 apps/web/modules/ee/role-management/components/add-member.test.tsx create mode 100644 apps/web/modules/ee/role-management/components/edit-membership-role.test.tsx create mode 100644 apps/web/modules/ee/role-management/tests/__mocks__/actions.mock.ts create mode 100644 apps/web/modules/ee/role-management/tests/actions.test.ts create mode 100644 apps/web/modules/organization/settings/teams/components/edit-memberships/organization-actions.test.tsx create mode 100644 apps/web/modules/organization/settings/teams/tests/__mocks__/actions.mock.ts create mode 100644 apps/web/modules/organization/settings/teams/tests/actions.test.ts diff --git a/apps/web/modules/ee/role-management/actions.ts b/apps/web/modules/ee/role-management/actions.ts index 1586680dc4..7149ace62d 100644 --- a/apps/web/modules/ee/role-management/actions.ts +++ b/apps/web/modules/ee/role-management/actions.ts @@ -8,9 +8,11 @@ import { updateMembership } from "@/modules/ee/role-management/lib/membership"; import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites"; import { z } from "zod"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getOrganization } from "@formbricks/lib/organization/service"; import { ZId, ZUuid } from "@formbricks/types/common"; import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors"; +import { AuthenticationError } from "@formbricks/types/errors"; import { ZMembershipUpdateInput } from "@formbricks/types/memberships"; export const checkRoleManagementPermission = async (organizationId: string) => { @@ -34,6 +36,14 @@ const ZUpdateInviteAction = z.object({ export const updateInviteAction = authenticatedActionClient .schema(ZUpdateInviteAction) .action(async ({ ctx, parsedInput }) => { + const currentUserMembership = await getMembershipByUserIdOrganizationId( + ctx.user.id, + parsedInput.organizationId + ); + if (!currentUserMembership) { + throw new AuthenticationError("User not a member of this organization"); + } + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, @@ -51,6 +61,10 @@ export const updateInviteAction = authenticatedActionClient throw new ValidationError("Billing role is not allowed"); } + if (currentUserMembership.role === "manager" && parsedInput.data.role !== "member") { + throw new OperationNotAllowedError("Managers can only invite members"); + } + await checkRoleManagementPermission(parsedInput.organizationId); return await updateInvite(parsedInput.inviteId, parsedInput.data); @@ -65,6 +79,14 @@ const ZUpdateMembershipAction = z.object({ export const updateMembershipAction = authenticatedActionClient .schema(ZUpdateMembershipAction) .action(async ({ ctx, parsedInput }) => { + const currentUserMembership = await getMembershipByUserIdOrganizationId( + ctx.user.id, + parsedInput.organizationId + ); + if (!currentUserMembership) { + throw new AuthenticationError("User not a member of this organization"); + } + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: parsedInput.organizationId, @@ -82,6 +104,10 @@ export const updateMembershipAction = authenticatedActionClient throw new ValidationError("Billing role is not allowed"); } + if (currentUserMembership.role === "manager" && parsedInput.data.role !== "member") { + throw new OperationNotAllowedError("Managers can only assign users to the member role"); + } + await checkRoleManagementPermission(parsedInput.organizationId); return await updateMembership(parsedInput.userId, parsedInput.organizationId, parsedInput.data); diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx index 6d0fba778e..3682070ea3 100644 --- a/apps/web/modules/ee/role-management/components/add-member-role.tsx +++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx @@ -10,25 +10,43 @@ import { SelectValue, } from "@/modules/ui/components/select"; import { Muted, P } from "@/modules/ui/components/typography"; -import { OrganizationRole } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; -import type { Control } from "react-hook-form"; -import { Controller } from "react-hook-form"; +import { useMemo } from "react"; +import { type Control, Controller } from "react-hook-form"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TOrganizationRole } from "@formbricks/types/memberships"; interface AddMemberRoleProps { control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>; canDoRoleManagement: boolean; isFormbricksCloud: boolean; + membershipRole?: TOrganizationRole; } -export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud }: AddMemberRoleProps) { - const roles = isFormbricksCloud - ? Object.values(OrganizationRole) - : Object.keys(OrganizationRole).filter((role) => role !== "billing"); +export function AddMemberRole({ + control, + canDoRoleManagement, + isFormbricksCloud, + membershipRole, +}: AddMemberRoleProps) { + const { isMember, isOwner } = getAccessFlags(membershipRole); const { t } = useTranslate(); + const roles = useMemo(() => { + let rolesArray = ["member"]; + + if (isOwner) { + rolesArray.push("owner", "manager"); + if (isFormbricksCloud) { + rolesArray.push("billing"); + } + } + return rolesArray; + }, [isOwner, isFormbricksCloud]); + + if (isMember) return null; + const rolesDescription = { owner: t("environments.settings.teams.owner_role_description"), manager: t("environments.settings.teams.manager_role_description"), @@ -44,7 +62,7 @@ export function AddMemberRole({ control, canDoRoleManagement, isFormbricksCloud