[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 !
This commit is contained in:
Marie
2025-03-18 18:42:30 +01:00
committed by GitHub
parent 489cc13fd9
commit 9e83d902d8
10 changed files with 331 additions and 0 deletions

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIndexOnRoleLabelAndWorkspaceId1742316060157
implements MigrationInterface
{
name = 'AddIndexOnRoleLabelAndWorkspaceId1742316060157';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."role" ADD CONSTRAINT "IndexOnRoleUnique" UNIQUE ("label", "workspaceId")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."role" DROP CONSTRAINT "IndexOnRoleUnique"`,
);
}
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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<RoleDTO> {
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<RoleDTO> {
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,

View File

@ -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<RoleEntity> {
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<RoleEntity> {
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<void> {
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,
);
}
}
}
}

View File

@ -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`);
}
};