[permissions] Update updateRole logic + disallow self role-assignment (#10476)

In this PR

- updateWorkspaceMemberRole api was changed to stop allowing null as a
valid value for roleId. it is not possible anymore to just unassign a
role from a user. instead it is only possible to assign a different role
to a user, which will unassign them from their previous role. For this
reason in the FE the bins icons next to the workspaceMember on a role
page were removed
- updateWorkspaceMemberRole will throw if a user attempts to update
their own role
- tests tests tests!
This commit is contained in:
Marie
2025-02-25 15:20:07 +01:00
committed by GitHub
parent 2247d3fa91
commit 9fe5c96d56
12 changed files with 253 additions and 224 deletions

View File

@ -1085,7 +1085,7 @@ export type MutationUpdateWorkspaceFeatureFlagArgs = {
export type MutationUpdateWorkspaceMemberRoleArgs = { export type MutationUpdateWorkspaceMemberRoleArgs = {
roleId?: InputMaybe<Scalars['String']>; roleId: Scalars['String'];
workspaceMemberId: Scalars['String']; workspaceMemberId: Scalars['String'];
}; };
@ -2368,7 +2368,7 @@ export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: str
export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{ export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{
workspaceMemberId: Scalars['String']; workspaceMemberId: Scalars['String'];
roleId?: InputMaybe<Scalars['String']>; roleId: Scalars['String'];
}>; }>;
@ -4229,7 +4229,7 @@ export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType<typeof use
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>; export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>;
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>; export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UpdateWorkspaceMemberRoleDocument = gql` export const UpdateWorkspaceMemberRoleDocument = gql`
mutation UpdateWorkspaceMemberRole($workspaceMemberId: String!, $roleId: String) { mutation UpdateWorkspaceMemberRole($workspaceMemberId: String!, $roleId: String!) {
updateWorkspaceMemberRole( updateWorkspaceMemberRole(
workspaceMemberId: $workspaceMemberId workspaceMemberId: $workspaceMemberId
roleId: $roleId roleId: $roleId

View File

@ -7,7 +7,7 @@ export const UPDATE_WORKSPACE_MEMBER_ROLE = gql`
${ROLE_FRAGMENT} ${ROLE_FRAGMENT}
mutation UpdateWorkspaceMemberRole( mutation UpdateWorkspaceMemberRole(
$workspaceMemberId: String! $workspaceMemberId: String!
$roleId: String $roleId: String!
) { ) {
updateWorkspaceMemberRole( updateWorkspaceMemberRole(
workspaceMemberId: $workspaceMemberId workspaceMemberId: $workspaceMemberId

View File

@ -24,7 +24,6 @@ import {
useUpdateWorkspaceMemberRoleMutation, useUpdateWorkspaceMemberRoleMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember'; import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { RoleAssignmentConfirmationModal } from './RoleAssignmentConfirmationModal'; import { RoleAssignmentConfirmationModal } from './RoleAssignmentConfirmationModal';
import { RoleAssignmentTableHeader } from './RoleAssignmentTableHeader'; import { RoleAssignmentTableHeader } from './RoleAssignmentTableHeader';
@ -73,8 +72,8 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
refetchQueries: [GetRolesDocument], refetchQueries: [GetRolesDocument],
}); });
const [modalMode, setModalMode] = const [confirmationModalIsOpen, setConfirmationModalIsOpen] =
useState<RoleAssignmentConfirmationModalMode | null>(null); useState<boolean>(false);
const [selectedWorkspaceMember, setSelectedWorkspaceMember] = const [selectedWorkspaceMember, setSelectedWorkspaceMember] =
useState<RoleAssignmentConfirmationModalSelectedWorkspaceMember | null>( useState<RoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
null, null,
@ -110,7 +109,7 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
}); });
const handleModalClose = () => { const handleModalClose = () => {
setModalMode(null); setConfirmationModalIsOpen(false);
setSelectedWorkspaceMember(null); setSelectedWorkspaceMember(null);
}; };
@ -122,26 +121,17 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`, name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: existingRole, role: existingRole,
}); });
setModalMode('assign'); setConfirmationModalIsOpen(true);
closeDropdown(); closeDropdown();
}; };
const handleRemoveClick = (workspaceMember: WorkspaceMember) => {
setSelectedWorkspaceMember({
id: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: workspaceMemberRoleMap.get(workspaceMember.id),
});
setModalMode('remove');
};
const handleConfirm = async () => { const handleConfirm = async () => {
if (!selectedWorkspaceMember || !modalMode) return; if (!selectedWorkspaceMember || !confirmationModalIsOpen) return;
await updateWorkspaceMemberRole({ await updateWorkspaceMemberRole({
variables: { variables: {
workspaceMemberId: selectedWorkspaceMember.id, workspaceMemberId: selectedWorkspaceMember.id,
roleId: modalMode === 'assign' ? role.id : null, roleId: role.id,
}, },
}); });
@ -183,7 +173,6 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
<RoleAssignmentTableRow <RoleAssignmentTableRow
key={workspaceMember.id} key={workspaceMember.id}
workspaceMember={workspaceMember} workspaceMember={workspaceMember}
onRemove={() => handleRemoveClick(workspaceMember)}
/> />
))} ))}
</Table> </Table>
@ -222,9 +211,8 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
/> />
</StyledBottomSection> </StyledBottomSection>
{modalMode && selectedWorkspaceMember && ( {confirmationModalIsOpen && selectedWorkspaceMember && (
<RoleAssignmentConfirmationModal <RoleAssignmentConfirmationModal
mode={modalMode}
selectedWorkspaceMember={selectedWorkspaceMember} selectedWorkspaceMember={selectedWorkspaceMember}
isOpen={true} isOpen={true}
onClose={handleModalClose} onClose={handleModalClose}

View File

@ -1,11 +1,9 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { RoleAssignmentConfirmationModalSubtitle } from '~/pages/settings/roles/components/RoleAssignmentConfirmationModalSubtitle'; import { RoleAssignmentConfirmationModalSubtitle } from '~/pages/settings/roles/components/RoleAssignmentConfirmationModalSubtitle';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember'; import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
type RoleAssignmentConfirmationModalProps = { type RoleAssignmentConfirmationModalProps = {
mode: RoleAssignmentConfirmationModalMode;
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember; selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@ -14,21 +12,15 @@ type RoleAssignmentConfirmationModalProps = {
}; };
export const RoleAssignmentConfirmationModal = ({ export const RoleAssignmentConfirmationModal = ({
mode,
selectedWorkspaceMember, selectedWorkspaceMember,
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
onRoleClick, onRoleClick,
}: RoleAssignmentConfirmationModalProps) => { }: RoleAssignmentConfirmationModalProps) => {
const isAssignMode = mode === 'assign';
const hasExistingRole = !!selectedWorkspaceMember.role;
const workspaceMemberName = selectedWorkspaceMember.name; const workspaceMemberName = selectedWorkspaceMember.name;
const title = isAssignMode const title = t`Assign ${workspaceMemberName}?`;
? t`Assign ${workspaceMemberName}?`
: t`Remove ${workspaceMemberName}?`;
return ( return (
<ConfirmationModal <ConfirmationModal
@ -37,14 +29,13 @@ export const RoleAssignmentConfirmationModal = ({
title={title} title={title}
subtitle={ subtitle={
<RoleAssignmentConfirmationModalSubtitle <RoleAssignmentConfirmationModalSubtitle
mode={mode}
selectedWorkspaceMember={selectedWorkspaceMember} selectedWorkspaceMember={selectedWorkspaceMember}
onRoleClick={onRoleClick} onRoleClick={onRoleClick}
/> />
} }
onConfirmClick={onConfirm} onConfirmClick={onConfirm}
deleteButtonText={isAssignMode ? t`Confirm` : t`Remove`} deleteButtonText={t`Confirm`}
confirmButtonAccent={isAssignMode && !hasExistingRole ? 'blue' : 'danger'} confirmButtonAccent="blue"
/> />
); );
}; };

View File

@ -2,7 +2,6 @@ import { SettingsCard } from '@/settings/components/SettingsCard';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { IconUser } from 'twenty-ui'; import { IconUser } from 'twenty-ui';
import { RoleAssignmentConfirmationModalMode } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalMode';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember'; import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '~/pages/settings/roles/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
const StyledSettingsCardContainer = styled.div` const StyledSettingsCardContainer = styled.div`
@ -10,40 +9,29 @@ const StyledSettingsCardContainer = styled.div`
`; `;
type RoleAssignmentConfirmationModalSubtitleProps = { type RoleAssignmentConfirmationModalSubtitleProps = {
mode: RoleAssignmentConfirmationModalMode;
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember; selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
onRoleClick: (roleId: string) => void; onRoleClick: (roleId: string) => void;
}; };
export const RoleAssignmentConfirmationModalSubtitle = ({ export const RoleAssignmentConfirmationModalSubtitle = ({
mode,
selectedWorkspaceMember, selectedWorkspaceMember,
onRoleClick, onRoleClick,
}: RoleAssignmentConfirmationModalSubtitleProps) => { }: RoleAssignmentConfirmationModalSubtitleProps) => {
const isAssignMode = mode === 'assign';
const hasExistingRole = !!selectedWorkspaceMember.role;
const workspaceMemberName = selectedWorkspaceMember.name; const workspaceMemberName = selectedWorkspaceMember.name;
if (isAssignMode && hasExistingRole) { return (
return ( <>
<> {t`${workspaceMemberName} will be unassigned from the following role:`}
{t`${workspaceMemberName} will be unassigned from the following role:`} <StyledSettingsCardContainer>
<StyledSettingsCardContainer> <SettingsCard
<SettingsCard title={selectedWorkspaceMember.role?.label || ''}
title={selectedWorkspaceMember.role?.label || ''} Icon={<IconUser />}
Icon={<IconUser />} onClick={() =>
onClick={() => selectedWorkspaceMember.role &&
selectedWorkspaceMember.role && onRoleClick(selectedWorkspaceMember.role.id)
onRoleClick(selectedWorkspaceMember.role.id) }
} />
/> </StyledSettingsCardContainer>
</StyledSettingsCardContainer> </>
</> );
);
}
return isAssignMode
? t`Are you sure you want to assign this role?`
: t`This member will be unassigned from this role.`;
}; };

View File

@ -2,13 +2,7 @@ import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { t } from '@lingui/core/macro'; import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui';
import {
Avatar,
IconButton,
IconTrash,
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { WorkspaceMember } from '~/generated-metadata/graphql';
const StyledTable = styled(Table)` const StyledTable = styled(Table)`
@ -21,27 +15,13 @@ const StyledIconWrapper = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)}; margin-right: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
margin-left: ${({ theme }) => theme.spacing(3)};
`;
type RoleAssignmentTableRowProps = { type RoleAssignmentTableRowProps = {
workspaceMember: WorkspaceMember; workspaceMember: WorkspaceMember;
onRemove: (workspaceMemberId: string) => void;
}; };
export const RoleAssignmentTableRow = ({ export const RoleAssignmentTableRow = ({
workspaceMember, workspaceMember,
onRemove,
}: RoleAssignmentTableRowProps) => { }: RoleAssignmentTableRowProps) => {
const handleRemoveClick = (event: React.MouseEvent) => {
event.stopPropagation();
onRemove(workspaceMember.id);
};
return ( return (
<StyledTable> <StyledTable>
<TableRow gridAutoColumns="150px 1fr 1fr"> <TableRow gridAutoColumns="150px 1fr 1fr">
@ -62,17 +42,6 @@ export const RoleAssignmentTableRow = ({
<TableCell> <TableCell>
<OverflowingTextWithTooltip text={workspaceMember.userEmail} /> <OverflowingTextWithTooltip text={workspaceMember.userEmail} />
</TableCell> </TableCell>
<TableCell align={'right'}>
<StyledButtonContainer>
<IconButton
onClick={handleRemoveClick}
variant="tertiary"
size="medium"
Icon={IconTrash}
aria-label={t`Remove`}
/>
</StyledButtonContainer>
</TableCell>
</TableRow> </TableRow>
</StyledTable> </StyledTable>
); );

View File

@ -1 +0,0 @@
export type RoleAssignmentConfirmationModalMode = 'assign' | 'remove';

View File

@ -18,6 +18,7 @@ export enum PermissionsExceptionCode {
CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN', CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN',
UNKNOWN_OPERATION_NAME = 'UNKNOWN_OPERATION_NAME', UNKNOWN_OPERATION_NAME = 'UNKNOWN_OPERATION_NAME',
UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION', UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION',
CANNOT_UPDATE_SELF_ROLE = 'CANNOT_UPDATE_SELF_ROLE',
} }
export enum PermissionsExceptionMessage { export enum PermissionsExceptionMessage {
@ -29,7 +30,8 @@ export enum PermissionsExceptionMessage {
USER_WORKSPACE_ALREADY_HAS_ROLE = 'User workspace already has role', USER_WORKSPACE_ALREADY_HAS_ROLE = 'User workspace already has role',
WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found', WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found',
ROLE_NOT_FOUND = 'Role not found', ROLE_NOT_FOUND = 'Role not found',
CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign last admin', CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign admin role from last admin of the workspace',
UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission', UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission',
UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission', UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission',
CANNOT_UPDATE_SELF_ROLE = 'Cannot update self role',
} }

View File

@ -14,6 +14,7 @@ export const permissionGraphqlApiExceptionHandler = (
switch (error.code) { switch (error.code) {
case PermissionsExceptionCode.PERMISSION_DENIED: case PermissionsExceptionCode.PERMISSION_DENIED:
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE:
throw new ForbiddenError(error.message); throw new ForbiddenError(error.message);
case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:

View File

@ -8,14 +8,18 @@ import {
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants'; import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { RoleService } from 'src/engine/metadata-modules/role/role.service';
@ -41,9 +45,17 @@ export class RoleResolver {
async updateWorkspaceMemberRole( async updateWorkspaceMemberRole(
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Args('workspaceMemberId') workspaceMemberId: string, @Args('workspaceMemberId') workspaceMemberId: string,
@Args('roleId', { type: () => String, nullable: true }) @Args('roleId', { type: () => String }) roleId: string,
roleId: string | null, @AuthWorkspaceMemberId()
updatorWorkspaceMemberId: string,
): Promise<WorkspaceMember> { ): Promise<WorkspaceMember> {
if (updatorWorkspaceMemberId === workspaceMemberId) {
throw new PermissionsException(
PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE,
PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE,
);
}
const workspaceMember = const workspaceMember =
await this.userWorkspaceService.getWorkspaceMemberOrThrow({ await this.userWorkspaceService.getWorkspaceMemberOrThrow({
workspaceMemberId, workspaceMemberId,
@ -56,18 +68,11 @@ export class RoleResolver {
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
if (!isDefined(roleId)) { await this.userRoleService.assignRoleToUserWorkspace({
await this.userRoleService.unassignAllRolesFromUserWorkspace({ userWorkspaceId: userWorkspace.id,
userWorkspaceId: userWorkspace.id, workspaceId: workspace.id,
workspaceId: workspace.id, roleId,
}); });
} else {
await this.userRoleService.assignRoleToUserWorkspace({
userWorkspaceId: userWorkspace.id,
workspaceId: workspace.id,
roleId,
});
}
const roles = await this.userRoleService const roles = await this.userRoleService
.getRolesByUserWorkspaces({ .getRolesByUserWorkspaces({

View File

@ -1,14 +1,15 @@
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { In, Repository } from 'typeorm'; import { In, Not, Repository } from 'typeorm';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { import {
PermissionsException, PermissionsException,
PermissionsExceptionCode, PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception'; } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@ -34,72 +35,22 @@ export class UserRoleService {
userWorkspaceId: string; userWorkspaceId: string;
roleId: string; roleId: string;
}): Promise<void> { }): Promise<void> {
const userWorkspace = await this.userWorkspaceRepository.findOne({ await this.validateAssignRoleInput({
where: { userWorkspaceId,
id: userWorkspaceId,
},
});
if (!isDefined(userWorkspace)) {
throw new PermissionsException(
'User workspace not found',
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
const role = await this.roleRepository.findOne({
where: {
id: roleId,
},
});
if (!isDefined(role)) {
throw new PermissionsException(
'Role not found',
PermissionsExceptionCode.ROLE_NOT_FOUND,
);
}
const roles = await this.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspace.id],
workspaceId, workspaceId,
});
const currentRole = roles.get(userWorkspace.id)?.[0];
if (currentRole?.id === roleId) {
return;
}
await this.unassignAllRolesFromUserWorkspace({
userWorkspaceId: userWorkspace.id,
workspaceId,
});
await this.userWorkspaceRoleRepository.save({
roleId, roleId,
userWorkspaceId: userWorkspace.id, });
const newUserWorkspaceRole = await this.userWorkspaceRoleRepository.save({
roleId,
userWorkspaceId,
workspaceId, workspaceId,
}); });
}
public async unassignAllRolesFromUserWorkspace({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<void> {
await this.validatesUserWorkspaceIsNotLastAdminIfUnassigningAdminRoleOrThrow(
{
userWorkspaceId,
workspaceId,
},
);
await this.userWorkspaceRoleRepository.delete({ await this.userWorkspaceRoleRepository.delete({
userWorkspaceId, userWorkspaceId,
workspaceId, workspaceId,
id: Not(newUserWorkspaceRole.id),
}); });
} }
@ -186,34 +137,64 @@ export class UserRoleService {
return workspaceMembers; return workspaceMembers;
} }
private async validatesUserWorkspaceIsNotLastAdminIfUnassigningAdminRoleOrThrow({ private async validateAssignRoleInput({
userWorkspaceId, userWorkspaceId,
workspaceId, workspaceId,
roleId,
}: { }: {
userWorkspaceId: string; userWorkspaceId: string;
workspaceId: string; workspaceId: string;
}): Promise<void> { roleId: string;
}) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
id: userWorkspaceId,
},
});
if (!isDefined(userWorkspace)) {
throw new PermissionsException(
'User workspace not found',
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
const role = await this.roleRepository.findOne({
where: {
id: roleId,
},
});
if (!isDefined(role)) {
throw new PermissionsException(
'Role not found',
PermissionsExceptionCode.ROLE_NOT_FOUND,
);
}
const roles = await this.getRolesByUserWorkspaces({ const roles = await this.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspaceId], userWorkspaceIds: [userWorkspace.id],
workspaceId, workspaceId,
}); });
const currentRoles = roles.get(userWorkspaceId); const currentRole = roles.get(userWorkspace.id)?.[0];
const adminRole = currentRoles?.find( if (currentRole?.id === roleId) {
(role: RoleDTO) => role.isEditable === false, return;
); }
if (isDefined(adminRole)) { if (!(currentRole?.label === ADMIN_ROLE_LABEL)) {
const workspaceMembersWithAdminRole = return;
await this.getWorkspaceMembersAssignedToRole(adminRole.id, workspaceId); }
if (workspaceMembersWithAdminRole.length === 1) { const workspaceMembersWithAdminRole =
throw new PermissionsException( await this.getWorkspaceMembersAssignedToRole(currentRole.id, workspaceId);
`Cannot unassign admin role as there is only one admin in the workspace`,
PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN, if (workspaceMembersWithAdminRole.length === 1) {
); throw new PermissionsException(
} PermissionsExceptionMessage.CANNOT_UNASSIGN_LAST_ADMIN,
PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN,
);
} }
} }
} }

View File

@ -3,8 +3,10 @@ import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graph
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { DEV_SEED_WORKSPACE_MEMBER_IDS } from 'src/database/typeorm-seeds/workspace/workspace-members';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`); const client = request(`http://localhost:${APP_PORT}`);
describe('roles permissions', () => { describe('roles permissions', () => {
@ -55,44 +57,46 @@ describe('roles permissions', () => {
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
expect(resp.body.errors).toBeUndefined(); expect(resp.body.errors).toBeUndefined();
expect(resp.body.data.getRoles).toHaveLength(3); expect(resp.body.data.getRoles).toHaveLength(3);
expect(resp.body.data.getRoles).toEqual([ expect(resp.body.data.getRoles).toEqual(
{ expect.arrayContaining([
label: 'Guest', {
workspaceMembers: [ label: 'Guest',
{ workspaceMembers: [
id: '20202020-1553-45c6-a028-5a9064cce07f', {
name: { id: '20202020-1553-45c6-a028-5a9064cce07f',
firstName: 'Phil', name: {
lastName: 'Schiler', firstName: 'Phil',
lastName: 'Schiler',
},
}, },
}, ],
], },
}, {
{ label: 'Admin',
label: 'Admin', workspaceMembers: [
workspaceMembers: [ {
{ id: '20202020-0687-4c41-b707-ed1bfca972a7',
id: '20202020-0687-4c41-b707-ed1bfca972a7', name: {
name: { firstName: 'Tim',
firstName: 'Tim', lastName: 'Apple',
lastName: 'Apple', },
}, },
}, ],
], },
}, {
{ label: 'Member',
label: 'Member', workspaceMembers: [
workspaceMembers: [ {
{ id: '20202020-77d5-4cb6-b60a-f4a835a85d61',
id: '20202020-77d5-4cb6-b60a-f4a835a85d61', name: {
name: { firstName: 'Jony',
firstName: 'Jony', lastName: 'Ive',
lastName: 'Ive', },
}, },
}, ],
], },
}, ]),
]); );
}); });
it('should throw a permission error when user does not have permission (member role)', async () => { it('should throw a permission error when user does not have permission (member role)', async () => {
const query = { const query = {
@ -129,7 +133,7 @@ describe('roles permissions', () => {
}); });
describe('updateWorkspaceMemberRole', () => { describe('updateWorkspaceMemberRole', () => {
it('should throw a permission error when user does not have permission (member role)', async () => { it('should throw a permission error when user does not have permission to update roles (member role)', async () => {
const query = { const query = {
query: ` query: `
mutation UpdateWorkspaceMemberRole { mutation UpdateWorkspaceMemberRole {
@ -154,5 +158,106 @@ describe('roles permissions', () => {
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
}); });
}); });
it('should throw a permission error when tries to update their own role (admin role)', async () => {
const query = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.TIM}", roleId: "test-role-id") {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE,
);
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
it('should allow to update role when user has permission (admin role)', async () => {
// Arrange
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const resp = await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
const memberRoleId = resp.body.data.getRoles.find(
(role) => role.label === 'Member',
).id;
const guestRoleId = resp.body.data.getRoles.find(
(role) => role.label === 'Guest',
).id;
const updateRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.PHIL}", roleId: "${memberRoleId}") {
id
}
}
`,
};
// Act and assert
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(updateRoleQuery)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateWorkspaceMemberRole.id).toBe(
DEV_SEED_WORKSPACE_MEMBER_IDS.PHIL,
);
});
// Clean
const rollbackRoleUpdateQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.PHIL}", roleId: "${guestRoleId}") {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(rollbackRoleUpdateQuery)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateWorkspaceMemberRole.id).toBe(
DEV_SEED_WORKSPACE_MEMBER_IDS.PHIL,
);
});
});
}); });
}); });