From 9e83d902d87ef99084b54bd684cf29982118902b Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Tue, 18 Mar 2025 18:42:30 +0100 Subject: [PATCH] [permissions V2] Create and update a custom role (without granularity) (#11003) First steps for https://github.com/twentyhq/core-team-issues/issues/595 and https://github.com/twentyhq/core-team-issues/issues/621 Not handling granular permissions through objectPermissions and settingsPermissions next; will come next ! --- ...60157-addIndexOnRoleLabelAndWorkspaceId.ts | 19 +++ .../enums/feature-flag-key.enum.ts | 1 + .../permissions/permissions.exception.ts | 5 + ...sion-graphql-api-exception-handler.util.ts | 5 + .../role/dtos/createRoleInput.dto.ts | 45 +++++++ .../role/dtos/updateRoleInput.dto.ts | 67 ++++++++++ .../metadata-modules/role/role.entity.ts | 2 + .../metadata-modules/role/role.resolver.ts | 53 ++++++++ .../metadata-modules/role/role.service.ts | 119 ++++++++++++++++++ ...s-arg-defined-if-provided-or-throw.util.ts | 15 +++ 10 files changed, 331 insertions(+) create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1742316060157-addIndexOnRoleLabelAndWorkspaceId.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/role/dtos/createRoleInput.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/role/dtos/updateRoleInput.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util.ts diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1742316060157-addIndexOnRoleLabelAndWorkspaceId.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1742316060157-addIndexOnRoleLabelAndWorkspaceId.ts new file mode 100644 index 000000000..1f51573fa --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1742316060157-addIndexOnRoleLabelAndWorkspaceId.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIndexOnRoleLabelAndWorkspaceId1742316060157 + implements MigrationInterface +{ + name = 'AddIndexOnRoleLabelAndWorkspaceId1742316060157'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."role" ADD CONSTRAINT "IndexOnRoleUnique" UNIQUE ("label", "workspaceId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."role" DROP CONSTRAINT "IndexOnRoleUnique"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 1383af6be..e0ee7f15f 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -14,4 +14,5 @@ export enum FeatureFlagKey { IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED', IsPermissionsEnabled = 'IS_PERMISSIONS_ENABLED', IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED', + IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index 542d9b0d1..c0fa16584 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -21,6 +21,9 @@ export enum PermissionsExceptionCode { UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION', CANNOT_UPDATE_SELF_ROLE = 'CANNOT_UPDATE_SELF_ROLE', NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'NO_ROLE_FOUND_FOR_USER_WORKSPACE', + INVALID_ARG = 'INVALID_ARG', + PERMISSIONS_V2_NOT_ENABLED = 'PERMISSIONS_V2_NOT_ENABLED', + ROLE_LABEL_ALREADY_EXISTS = 'ROLE_LABEL_ALREADY_EXISTS', } export enum PermissionsExceptionMessage { @@ -38,4 +41,6 @@ export enum PermissionsExceptionMessage { UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission', CANNOT_UPDATE_SELF_ROLE = 'Cannot update self role', NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'No role found for userWorkspace', + PERMISSIONS_V2_NOT_ENABLED = 'Permissions V2 is not enabled', + ROLE_LABEL_ALREADY_EXISTS = 'A role with this label already exists', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts index b05a63ae6..0d96d4619 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -2,6 +2,7 @@ import { ForbiddenError, InternalServerError, NotFoundError, + UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { PermissionsException, @@ -16,7 +17,11 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE: case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER: + case PermissionsExceptionCode.PERMISSIONS_V2_NOT_ENABLED: + case PermissionsExceptionCode.ROLE_LABEL_ALREADY_EXISTS: throw new ForbiddenError(error.message); + case PermissionsExceptionCode.INVALID_ARG: + throw new UserInputError(error.message); case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: throw new NotFoundError(error.message); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/createRoleInput.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/createRoleInput.dto.ts new file mode 100644 index 000000000..00c6f234d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/createRoleInput.dto.ts @@ -0,0 +1,45 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +@InputType() +export class CreateRoleInput { + @IsString() + @Field({ nullable: false }) + label: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + icon?: string; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canUpdateAllSettings?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canReadAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canUpdateAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canSoftDeleteAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canDestroyAllObjectRecords?: boolean; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/updateRoleInput.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/updateRoleInput.dto.ts new file mode 100644 index 000000000..c355f77c4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/updateRoleInput.dto.ts @@ -0,0 +1,67 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class UpdateRolePayload { + @IsString() + @IsOptional() + @Field({ nullable: true }) + label?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + icon?: string; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canUpdateAllSettings?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canReadAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canUpdateAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canSoftDeleteAllObjectRecords?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + canDestroyAllObjectRecords?: boolean; +} + +@InputType() +export class UpdateRoleInput { + @Field(() => UpdateRolePayload) + update: UpdateRolePayload; + + @IsNotEmpty() + @Field(() => UUIDScalarType, { + description: 'The id of the role to update', + }) + @IsUUID() + id!: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts index 5b7fa4cc0..c27510abe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts @@ -5,6 +5,7 @@ import { OneToMany, PrimaryGeneratedColumn, Relation, + Unique, UpdateDateColumn, } from 'typeorm'; @@ -13,6 +14,7 @@ import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-w import { SettingsPermissionsEntity } from 'src/engine/metadata-modules/settings-permissions/settings-permissions.entity'; @Entity('role') +@Unique('IndexOnRoleUnique', ['label', 'workspaceId']) export class RoleEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 15f8b22d0..3658f4ba8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -8,6 +8,8 @@ import { Resolver, } from '@nestjs/graphql'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -21,7 +23,9 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/createRoleInput.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; +import { UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/updateRoleInput.dto'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @@ -34,6 +38,7 @@ export class RoleResolver { private readonly userRoleService: UserRoleService, private readonly roleService: RoleService, private readonly userWorkspaceService: UserWorkspaceService, + private readonly featureFlagService: FeatureFlagService, ) {} @Query(() => [RoleDTO]) @@ -91,6 +96,54 @@ export class RoleResolver { } as WorkspaceMember; } + @Mutation(() => RoleDTO) + async createOneRole( + @AuthWorkspace() workspace: Workspace, + @Args('createRoleInput') createRoleInput: CreateRoleInput, + ): Promise { + const isPermissionsV2Enabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsPermissionsV2Enabled, + workspace.id, + ); + + if (!isPermissionsV2Enabled) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSIONS_V2_NOT_ENABLED, + PermissionsExceptionCode.PERMISSIONS_V2_NOT_ENABLED, + ); + } + + return this.roleService.createRole({ + workspaceId: workspace.id, + input: createRoleInput, + }); + } + + @Mutation(() => RoleDTO) + async updateOneRole( + @AuthWorkspace() workspace: Workspace, + @Args('updateRoleInput') updateRoleInput: UpdateRoleInput, + ): Promise { + const isPermissionsV2Enabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsPermissionsV2Enabled, + workspace.id, + ); + + if (!isPermissionsV2Enabled) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSIONS_V2_NOT_ENABLED, + PermissionsExceptionCode.PERMISSIONS_V2_NOT_ENABLED, + ); + } + + return this.roleService.updateRole({ + input: updateRoleInput, + workspaceId: workspace.id, + }); + } + @ResolveField('workspaceMembers', () => [WorkspaceMember]) async getWorkspaceMembersAssignedToRole( @Parent() role: RoleDTO, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts index 85129c3b6..81d16aa7c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts @@ -1,10 +1,22 @@ import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined } from 'twenty-shared'; import { Repository } from 'typeorm'; 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 { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/createRoleInput.dto'; +import { + UpdateRoleInput, + UpdateRolePayload, +} from 'src/engine/metadata-modules/role/dtos/updateRoleInput.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { isArgDefinedIfProvidedOrThrow } from 'src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util'; export class RoleService { constructor( @@ -34,6 +46,64 @@ export class RoleService { }); } + public async createRole({ + input, + workspaceId, + }: { + input: CreateRoleInput; + workspaceId: string; + }): Promise { + await this.validateRoleInput({ input, workspaceId }); + + return this.roleRepository.save({ + label: input.label, + description: input.description, + icon: input.icon, + canUpdateAllSettings: input.canUpdateAllSettings, + canReadAllObjectRecords: input.canReadAllObjectRecords, + canUpdateAllObjectRecords: input.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: input.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: input.canDestroyAllObjectRecords, + isEditable: true, + workspaceId, + }); + } + + public async updateRole({ + input, + workspaceId, + }: { + input: UpdateRoleInput; + workspaceId: string; + }): Promise { + const existingRole = await this.roleRepository.findOne({ + where: { + id: input.id, + workspaceId, + }, + }); + + if (!isDefined(existingRole)) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_NOT_FOUND, + PermissionsExceptionCode.ROLE_NOT_FOUND, + ); + } + + await this.validateRoleInput({ + input: input.update, + workspaceId, + roleId: input.id, + }); + + const updatedRole = await this.roleRepository.save({ + id: input.id, + ...input.update, + }); + + return { ...existingRole, ...updatedRole }; + } + public async createAdminRole({ workspaceId, }: { @@ -91,4 +161,53 @@ export class RoleService { workspaceId, }); } + + private async validateRoleInput({ + input, + workspaceId, + roleId, + }: { + input: CreateRoleInput | UpdateRolePayload; + workspaceId: string; + roleId?: string; + }): Promise { + const keysToValidate = [ + 'label', + 'canUpdateAllSettings', + 'canReadAllObjectRecords', + 'canUpdateAllObjectRecords', + 'canSoftDeleteAllObjectRecords', + 'canDestroyAllObjectRecords', + ]; + + for (const key of keysToValidate) { + try { + isArgDefinedIfProvidedOrThrow({ + input, + key, + value: input[key], + }); + } catch (error) { + throw new PermissionsException( + error.message, + PermissionsExceptionCode.INVALID_ARG, + ); + } + } + + if (isDefined(input.label)) { + let workspaceRoles = await this.getWorkspaceRoles(workspaceId); + + if (isDefined(roleId)) { + workspaceRoles = workspaceRoles.filter((role) => role.id !== roleId); + } + + if (workspaceRoles.some((role) => role.label === input.label)) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_LABEL_ALREADY_EXISTS, + PermissionsExceptionCode.ROLE_LABEL_ALREADY_EXISTS, + ); + } + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util.ts new file mode 100644 index 000000000..cde7e46d6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared'; + +export const isArgDefinedIfProvidedOrThrow = ({ + input, + key, + value, +}: { + input: object; + key: string; + value: any; +}) => { + if (key in input && !isDefined(value)) { + throw new Error(`${key} must be defined when provided`); + } +};