[Permissions] Implement getRoles (#9955)

In this PR

- introducing roles module to separate roles logic (assign a Role, get a
workspace's roles etc.) from permission logic (check if a user has a
permission)
- Introduces getRoles endpoint to fetch a workspace's roles
- introduces the first permission check: getRoles in only accessible to
users with permission on ROLE setting. Implemented
validatesUserHasWorkspaceSettingPermissionOrThrow
This commit is contained in:
Marie
2025-02-03 19:14:18 +01:00
committed by GitHub
parent caee5b1f89
commit 351e768038
18 changed files with 413 additions and 50 deletions

View File

@ -3,8 +3,10 @@ import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RelationMetadataModule } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.module';
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@ -19,6 +21,8 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
RemoteServerModule,
RoleModule,
PermissionsModule,
],
providers: [],
exports: [
@ -28,6 +32,8 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
RelationMetadataModule,
ServerlessFunctionModule,
RemoteServerModule,
RoleModule,
PermissionsModule,
],
})
export class MetadataEngineModule {}

View File

@ -12,4 +12,6 @@ export enum PermissionsExceptionCode {
USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND',
WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH = 'WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH',
TOO_MANY_ADMIN_CANDIDATES = 'TOO_MANY_ADMIN_CANDIDATES',
USER_WORKSPACE_ALREADY_HAS_ROLE = 'USER_WORKSPACE_ALREADY_HAS_ROLE',
PERMISSION_DENIED = 'PERMISSION_DENIED',
}

View File

@ -1,17 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
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 { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleEntity } from 'src/engine/metadata-modules/permissions/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/permissions/user-workspace-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 { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'metadata'),
FeatureFlagModule,
TypeOrmModule.forFeature([UserWorkspace], 'core'),
EnvironmentModule,
UserRoleModule,
],
providers: [PermissionsService],
exports: [PermissionsService],

View File

@ -1,71 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared';
import { SettingsFeatures } from 'twenty-shared';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
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 {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleEntity } from 'src/engine/metadata-modules/permissions/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/permissions/user-workspace-role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service';
@Injectable()
export class PermissionsService {
constructor(
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'metadata')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly environmentService: EnvironmentService,
private readonly userRoleService: UserRoleService,
) {}
public async createAdminRole({
workspaceId,
public async getUserWorkspaceSettingsPermissions({
userWorkspaceId,
}: {
workspaceId: string;
}): Promise<RoleEntity> {
return this.roleRepository.save({
label: ADMIN_ROLE_LABEL,
description: 'Admin role',
canUpdateAllSettings: true,
isEditable: false,
workspaceId,
});
userWorkspaceId: string;
}): Promise<Record<SettingsFeatures, boolean>> {
const roleOfUserWorkspace =
await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId);
let hasPermissionOnSettingFeature = false;
if (roleOfUserWorkspace?.canUpdateAllSettings === true) {
hasPermissionOnSettingFeature = true;
}
return Object.keys(SettingsFeatures).reduce(
(acc, feature) => ({
...acc,
[feature]: hasPermissionOnSettingFeature,
}),
{} as Record<SettingsFeatures, boolean>,
);
}
public async assignRoleToUserWorkspace({
workspaceId,
public async validateUserHasWorkspaceSettingPermissionOrThrow({
userWorkspaceId,
roleId,
setting,
}: {
workspaceId: string;
userWorkspaceId: string;
roleId: string;
setting: SettingsFeatures;
}): Promise<void> {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
id: userWorkspaceId,
},
});
const userWorkspaceRole =
await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId);
if (!isDefined(userWorkspace)) {
throw new PermissionsException(
'User workspace not found',
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
if (userWorkspaceRole?.canUpdateAllSettings === true) {
return;
}
await this.userWorkspaceRoleRepository.save({
roleId,
userWorkspaceId: userWorkspace.id,
workspaceId,
});
throw new PermissionsException(
`User does not have permission to update this setting: ${setting}`,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
public async isPermissionsEnabled(): Promise<boolean> {

View File

@ -0,0 +1,21 @@
import {
ForbiddenError,
InternalServerError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
export const permissionsGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof PermissionsException) {
switch (error.code) {
case PermissionsExceptionCode.PERMISSION_DENIED:
throw new ForbiddenError(error.message);
default:
throw new InternalServerError(error.message);
}
}
throw error;
};

View File

@ -0,0 +1,30 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import { Relation } from 'typeorm';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
@ObjectType()
export class RoleDTO {
@Field({ nullable: false })
id: string;
@Field({ nullable: false })
label: string;
@Field({ nullable: false })
canUpdateAllSettings: boolean;
@Field({ nullable: true })
description: string;
@Field({ nullable: false })
isEditable: boolean;
@HideField()
userWorkspaceRoles: Relation<UserWorkspaceRoleEntity[]>;
@Field(() => [WorkspaceMember], { nullable: true })
workspaceMembers?: WorkspaceMember[];
}

View File

@ -8,7 +8,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/permissions/user-workspace-role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
@Entity('role')
export class RoleEntity {

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'metadata'),
TypeOrmModule.forFeature([UserWorkspace], 'core'),
UserRoleModule,
PermissionsModule,
],
providers: [RoleService, RoleResolver],
exports: [RoleService],
})
export class RoleModule {}

View File

@ -0,0 +1,65 @@
import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { SettingsFeatures } from 'twenty-shared';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { permissionsGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception-handler';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Resolver(() => RoleDTO)
export class RoleResolver {
constructor(
private readonly userRoleService: UserRoleService,
private readonly permissionsService: PermissionsService,
private readonly roleService: RoleService,
) {}
@Query(() => [RoleDTO])
async getRoles(
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: Workspace,
): Promise<RoleDTO[]> {
try {
await this.permissionsService.validateUserHasWorkspaceSettingPermissionOrThrow(
{
userWorkspaceId,
setting: SettingsFeatures.ROLES,
},
);
const roles = await this.roleService.getWorkspaceRoles(workspace.id);
return roles.map((role) => ({
id: role.id,
label: role.label,
canUpdateAllSettings: role.canUpdateAllSettings,
description: role.description,
workspaceId: role.workspaceId,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
isEditable: role.isEditable,
userWorkspaceRoles: role.userWorkspaceRoles,
}));
} catch (error) {
return permissionsGraphqlApiExceptionHandler(error);
}
}
@ResolveField('workspaceMembers', () => [WorkspaceMember])
async getWorkspaceMembersAssignedToRole(
@Parent() role: RoleDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<WorkspaceMemberWorkspaceEntity[]> {
return this.userRoleService.getWorkspaceMembersAssignedToRole(
role.id,
workspace.id,
);
}
}

View File

@ -0,0 +1,36 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
export class RoleService {
constructor(
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
) {}
public async getWorkspaceRoles(workspaceId: string): Promise<RoleEntity[]> {
return this.roleRepository.find({
where: {
workspaceId,
},
relations: ['userWorkspaceRoles'],
});
}
public async createAdminRole({
workspaceId,
}: {
workspaceId: string;
}): Promise<RoleEntity> {
return this.roleRepository.save({
label: ADMIN_ROLE_LABEL,
description: 'Admin role',
canUpdateAllSettings: true,
isEditable: false,
workspaceId,
});
}
}

View File

@ -2,6 +2,7 @@ import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
@ -9,7 +10,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { RoleEntity } from 'src/engine/metadata-modules/permissions/role.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Entity('userWorkspaceRole')
@Unique('IndexOnUserWorkspaceRoleUnique', ['userWorkspaceId', 'roleId'])
@ -26,6 +27,7 @@ export class UserWorkspaceRoleEntity {
@ManyToOne(() => RoleEntity, (role) => role.userWorkspaceRoles, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'roleId' })
role: Relation<RoleEntity>;
@Column({ nullable: false, type: 'uuid' })

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
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/userRole/userRole.service';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'metadata'),
TypeOrmModule.forFeature([UserWorkspace], 'core'),
],
providers: [UserRoleService],
exports: [UserRoleService],
})
export class UserRoleModule {}

View File

@ -0,0 +1,148 @@
import { InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared';
import { In, Repository } from 'typeorm';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleEntity } from 'src/engine/metadata-modules/role/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 { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export class UserRoleService {
constructor(
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'metadata')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
public async unassignRolesFromUserWorkspace({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<void> {
const userWorkspaceRoles = await this.userWorkspaceRoleRepository.find({
where: {
userWorkspaceId,
workspaceId,
},
});
if (!isEmpty(userWorkspaceRoles)) {
userWorkspaceRoles.forEach(async (userWorkspaceRole) => {
await this.userWorkspaceRoleRepository.delete({
id: userWorkspaceRole.id,
});
});
}
}
public async assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId,
roleId,
}: {
workspaceId: string;
userWorkspaceId: string;
roleId: string;
}): Promise<void> {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
id: userWorkspaceId,
},
});
if (!isDefined(userWorkspace)) {
throw new PermissionsException(
'User workspace not found',
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
await this.unassignRolesFromUserWorkspace({
userWorkspaceId: userWorkspace.id,
workspaceId,
});
await this.userWorkspaceRoleRepository.save({
roleId,
userWorkspaceId: userWorkspace.id,
workspaceId,
});
}
public async getRoleForUserWorkspace(
userWorkspaceId: string,
): Promise<RoleEntity | null> {
if (!isDefined(userWorkspaceId)) {
return null;
}
const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({
where: {
userWorkspaceId,
},
});
if (!isDefined(userWorkspaceRole)) {
return null;
}
return await this.roleRepository.findOne({
where: {
id: userWorkspaceRole.roleId,
},
});
}
public async getWorkspaceMembersAssignedToRole(
roleId: string,
workspaceId: string,
): Promise<WorkspaceMemberWorkspaceEntity[]> {
const userWorkspaceRoles = await this.userWorkspaceRoleRepository.find({
where: {
roleId,
workspaceId,
},
});
const userIds = await this.userWorkspaceRepository
.find({
where: {
id: In(
userWorkspaceRoles.map(
(userWorkspaceRole) => userWorkspaceRole.userWorkspaceId,
),
),
},
})
.then((userWorkspaces) =>
userWorkspaces.map((userWorkspace) => userWorkspace.userId),
);
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers = await workspaceMemberRepository.find({
where: {
userId: In(userIds),
},
});
return workspaceMembers;
}
}

View File

@ -146,6 +146,7 @@ export class MiddlewareService {
request.workspaceId = data.workspace.id;
request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
}
private getStatus(error: any): number {

View File

@ -6,6 +6,8 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { UserRoleModule } from 'src/engine/metadata-modules/userRole/userRole.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { SeederModule } from 'src/engine/seeder/seeder.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@ -26,6 +28,8 @@ import { WorkspaceManagerService } from './workspace-manager.service';
FeatureFlagModule,
PermissionsModule,
TypeOrmModule.forFeature([UserWorkspace], 'core'),
RoleModule,
UserRoleModule,
],
exports: [WorkspaceManagerService],
providers: [WorkspaceManagerService],

View File

@ -13,6 +13,8 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { PETS_DATA_SEEDS } from 'src/engine/seeder/data-seeds/pets-data-seeds';
import { SURVEY_RESULTS_DATA_SEEDS } from 'src/engine/seeder/data-seeds/survey-results-data-seeds';
@ -36,6 +38,8 @@ export class WorkspaceManagerService {
private readonly permissionsService: PermissionsService,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly roleService: RoleService,
private readonly userRoleService: UserRoleService,
) {}
/**
@ -188,7 +192,7 @@ export class WorkspaceManagerService {
}
private async initPermissions(workspaceId: string) {
const adminRole = await this.permissionsService.createAdminRole({
const adminRole = await this.roleService.createAdminRole({
workspaceId,
});
@ -212,7 +216,7 @@ export class WorkspaceManagerService {
);
}
await this.permissionsService.assignRoleToUserWorkspace({
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: userWorkspace[0].id,
roleId: adminRole.id,

View File

@ -0,0 +1,9 @@
export enum SettingsFeatures {
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
WORKSPACE_SETTINGS = 'WORKSPACE_SETTINGS',
WORKSPACE_USERS = 'WORKSPACE_USERS',
ROLES = 'WORKSPACE_ROLES',
DATA_MODEL = 'DATA_MODEL',
ADMIN_PANEL = 'ADMIN_PANEL',
SECURITY_SETTINGS = 'SECURITY_SETTINGS',
}

View File

@ -1,5 +1,6 @@
export * from './constants/FieldForTotalCountAggregateOperation';
export * from './constants/Locales';
export * from './constants/SettingsFeatures';
export * from './constants/TwentyCompaniesBaseUrl';
export * from './constants/TwentyIconsBaseUrl';
export * from './types/ConnectedAccountProvider';