[permissions V2] Custom role deletion (#11187)

Closes https://github.com/twentyhq/core-team-issues/issues/616
This commit is contained in:
Marie
2025-03-26 15:08:48 +01:00
committed by GitHub
parent 16cb768c5c
commit 7af90eb4c4
9 changed files with 365 additions and 100 deletions

View File

@ -2,9 +2,9 @@
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@ -37,8 +37,6 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(ObjectMetadataEntity, 'metadata')

View File

@ -31,6 +31,11 @@ export class ObjectPermissionService {
input: UpsertObjectPermissionInput;
}): Promise<ObjectPermissionEntity | null> {
try {
await this.validateRoleIsEditableOrThrow({
roleId: input.roleId,
workspaceId,
});
const result = await this.objectPermissionRepository.upsert(
{
workspaceId,
@ -76,7 +81,12 @@ export class ObjectPermissionService {
objectMetadataId: string;
}) {
if (error.message.includes('violates foreign key constraint')) {
const role = await this.getRole(roleId, workspaceId);
const role = await this.roleRepository.findOne({
where: {
id: roleId,
workspaceId,
},
});
if (!isDefined(role)) {
throw new PermissionsException(
@ -101,15 +111,25 @@ export class ObjectPermissionService {
}
}
private async getRole(
roleId: string,
workspaceId: string,
): Promise<RoleEntity | null> {
return this.roleRepository.findOne({
private async validateRoleIsEditableOrThrow({
roleId,
workspaceId,
}: {
roleId: string;
workspaceId: string;
}) {
const role = await this.roleRepository.findOne({
where: {
id: roleId,
workspaceId,
},
});
if (!role?.isEditable) {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
);
}
}
}

View File

@ -28,6 +28,7 @@ export enum PermissionsExceptionCode {
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
INVALID_SETTING = 'INVALID_SETTING',
ROLE_NOT_EDITABLE = 'ROLE_NOT_EDITABLE',
DEFAULT_ROLE_CANNOT_BE_DELETED = 'DEFAULT_ROLE_CANNOT_BE_DELETED',
}
export enum PermissionsExceptionMessage {
@ -51,4 +52,5 @@ export enum PermissionsExceptionMessage {
OBJECT_METADATA_NOT_FOUND = 'Object metadata not found',
INVALID_SETTING = 'Invalid permission setting (unknown value)',
ROLE_NOT_EDITABLE = 'Role is not editable',
DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted',
}

View File

@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@ -16,7 +17,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'metadata'),
TypeOrmModule.forFeature([UserWorkspace], 'core'),
TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'),
UserRoleModule,
PermissionsModule,
UserWorkspaceModule,

View File

@ -123,10 +123,6 @@ export class RoleResolver {
@Args('updateRoleInput') updateRoleInput: UpdateRoleInput,
): Promise<RoleDTO> {
await this.validatePermissionsV2EnabledOrThrow(workspace);
await this.validateRoleIsEditableOrThrow({
roleId: updateRoleInput.id,
workspaceId: workspace.id,
});
return this.roleService.updateRole({
input: updateRoleInput,
@ -134,6 +130,16 @@ export class RoleResolver {
});
}
@Mutation(() => String)
async deleteOneRole(
@AuthWorkspace() workspace: Workspace,
@Args('roleId') roleId: string,
): Promise<string> {
await this.validatePermissionsV2EnabledOrThrow(workspace);
return this.roleService.deleteRole(roleId, workspace.id);
}
@Mutation(() => ObjectPermissionDTO)
async upsertOneObjectPermission(
@AuthWorkspace() workspace: Workspace,
@ -141,10 +147,6 @@ export class RoleResolver {
upsertObjectPermissionInput: UpsertObjectPermissionInput,
) {
await this.validatePermissionsV2EnabledOrThrow(workspace);
await this.validateRoleIsEditableOrThrow({
roleId: upsertObjectPermissionInput.roleId,
workspaceId: workspace.id,
});
return this.objectPermissionService.upsertObjectPermission({
workspaceId: workspace.id,
@ -159,10 +161,6 @@ export class RoleResolver {
upsertSettingPermissionInput: UpsertSettingPermissionInput,
) {
await this.validatePermissionsV2EnabledOrThrow(workspace);
await this.validateRoleIsEditableOrThrow({
roleId: upsertSettingPermissionInput.roleId,
workspaceId: workspace.id,
});
return this.settingPermissionService.upsertSettingPermission({
workspaceId: workspace.id,
@ -195,21 +193,4 @@ export class RoleResolver {
);
}
}
private async validateRoleIsEditableOrThrow({
roleId,
workspaceId,
}: {
roleId: string;
workspaceId: string;
}) {
const role = await this.roleService.getRoleById(roleId, workspaceId);
if (!role?.isEditable) {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
);
}
}
}

View File

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { MEMBER_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/member-role-label.constants';
import {
@ -16,12 +17,19 @@ import {
UpdateRolePayload,
} from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { isArgDefinedIfProvidedOrThrow } from 'src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util';
export class RoleService {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'metadata')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
private readonly userRoleService: UserRoleService,
) {}
public async getWorkspaceRoles(workspaceId: string): Promise<RoleEntity[]> {
@ -76,6 +84,11 @@ export class RoleService {
input: UpdateRoleInput;
workspaceId: string;
}): Promise<RoleEntity> {
await this.validateRoleIsEditableOrThrow({
roleId: input.id,
workspaceId,
});
const existingRole = await this.roleRepository.findOne({
where: {
id: input.id,
@ -123,6 +136,49 @@ export class RoleService {
});
}
public async deleteRole(
roleId: string,
workspaceId: string,
): Promise<string> {
await this.validateRoleIsEditableOrThrow({
roleId,
workspaceId,
});
const defaultRole = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
},
});
const defaultRoleId = defaultRole?.defaultRoleId;
if (!isDefined(defaultRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND,
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
);
}
await this.validateRoleIsNotDefaultRoleOrThrow({
roleId,
defaultRoleId,
});
await this.assignDefaultRoleToMembersWithRoleToDelete({
roleId,
workspaceId,
defaultRoleId,
});
await this.roleRepository.delete({
id: roleId,
workspaceId,
});
return roleId;
}
public async createMemberRole({
workspaceId,
}: {
@ -210,4 +266,91 @@ export class RoleService {
}
}
}
private async validateRoleIsNotDefaultRoleOrThrow({
roleId,
defaultRoleId,
}: {
roleId: string;
defaultRoleId: string;
}): Promise<void> {
if (defaultRoleId === roleId) {
throw new PermissionsException(
PermissionsExceptionMessage.DEFAULT_ROLE_CANNOT_BE_DELETED,
PermissionsExceptionCode.DEFAULT_ROLE_CANNOT_BE_DELETED,
);
}
}
private async assignDefaultRoleToMembersWithRoleToDelete({
roleId,
workspaceId,
defaultRoleId,
}: {
roleId: string;
workspaceId: string;
defaultRoleId: string;
}): Promise<void> {
const userWorkspaceIds = await this.getUserWorkspaceIdsForRole(
roleId,
workspaceId,
);
await Promise.all(
userWorkspaceIds.map((userWorkspaceId) =>
this.userRoleService.assignRoleToUserWorkspace({
userWorkspaceId,
roleId: defaultRoleId,
workspaceId,
}),
),
);
}
private async getUserWorkspaceIdsForRole(
roleId: string,
workspaceId: string,
): Promise<string[]> {
return this.userWorkspaceRoleRepository
.find({
where: {
roleId: roleId,
workspaceId,
},
})
.then((userWorkspaceRoles) =>
userWorkspaceRoles.map(
(userWorkspaceRole) => userWorkspaceRole.userWorkspaceId,
),
);
}
private async getRole(
roleId: string,
workspaceId: string,
): Promise<RoleEntity | null> {
return this.roleRepository.findOne({
where: {
id: roleId,
workspaceId,
},
});
}
private async validateRoleIsEditableOrThrow({
roleId,
workspaceId,
}: {
roleId: string;
workspaceId: string;
}) {
const role = await this.getRole(roleId, workspaceId);
if (!role?.isEditable) {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
);
}
}
}

View File

@ -28,6 +28,11 @@ export class SettingPermissionService {
workspaceId: string;
input: UpsertSettingPermissionInput;
}): Promise<SettingPermissionEntity | null | undefined> {
await this.validateRoleIsEditableOrThrow({
roleId: input.roleId,
workspaceId,
});
if (!Object.values(SettingPermissionType).includes(input.setting)) {
throw new PermissionsException(
PermissionsExceptionMessage.INVALID_SETTING,
@ -76,4 +81,26 @@ export class SettingPermissionService {
throw error;
}
}
private async validateRoleIsEditableOrThrow({
roleId,
workspaceId,
}: {
roleId: string;
workspaceId: string;
}) {
const role = await this.roleRepository.findOne({
where: {
id: roleId,
workspaceId,
},
});
if (!role?.isEditable) {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
);
}
}
}