[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:
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user