Skip to content

Commit

Permalink
Implement updateRole (twentyhq#10009)
Browse files Browse the repository at this point in the history
In this PR, we are implementing the updateRole endpoint with the
following rules

1. A user can only update a member's role if they have the permission (=
the admin role)
2. Admin role can't be unassigned if there are no other admin in the
workspace
3. (For now) as members can only have one role for now, when they are
assigned a new role, they are first unassigned the other role (if any)
4. (For now) removing a member's admin role = leaving the member with no
role = calling updateRole with a null roleId
  • Loading branch information
ijreilly authored and eliezer-rodrigues037 committed Feb 7, 2025
1 parent 851d6c8 commit a3bb8e0
Show file tree
Hide file tree
Showing 15 changed files with 303 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';

@Module({
Expand All @@ -28,6 +29,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
DataSourceModule,
WorkspaceDataSourceModule,
WorkspaceInvitationModule,
TwentyORMModule,
],
services: [UserWorkspaceService],
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { InjectRepository } from '@nestjs/typeorm';

import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';

import { TypeORMService } from 'src/database/typeorm/typeorm.service';
Expand All @@ -19,7 +20,9 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';

export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
Expand All @@ -34,6 +37,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly typeORMService: TypeORMService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(userWorkspaceRepository);
}
Expand Down Expand Up @@ -196,4 +200,51 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
),
}));
}

async getUserWorkspaceForUserOrThrow({
userId,
workspaceId,
}: {
userId: string;
workspaceId: string;
}): Promise<UserWorkspace> {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId,
workspaceId,
},
});

if (!isDefined(userWorkspace)) {
throw new Error('User workspace not found');
}

return userWorkspace;
}

async getWorkspaceMemberOrThrow({
workspaceMemberId,
workspaceId,
}: {
workspaceMemberId: string;
workspaceId: string;
}): Promise<WorkspaceMemberWorkspaceEntity> {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);

const workspaceMember = await workspaceMemberRepository.findOne({
where: {
id: workspaceMemberId,
},
});

if (!isDefined(workspaceMember)) {
throw new Error('Workspace member not found');
}

return workspaceMember;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';

import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import {
WorkspaceMemberDateFormatEnum,
WorkspaceMemberTimeFormatEnum,
Expand Down Expand Up @@ -42,4 +43,10 @@ export class WorkspaceMember {

@Field(() => WorkspaceMemberTimeFormatEnum, { nullable: true })
timeFormat: WorkspaceMemberTimeFormatEnum;

@Field(() => [RoleDTO], { nullable: true })
roles?: RoleDTO[];

@Field(() => String)
userWorkspaceId: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserResolver } from 'src/engine/core-modules/user/user.resolver';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';

import { userAutoResolverOpts } from './user.auto-resolver-opts';

Expand All @@ -39,10 +42,12 @@ import { UserService } from './services/user.service';
FileUploadModule,
WorkspaceModule,
OnboardingModule,
TypeOrmModule.forFeature([KeyValuePair], 'core'),
TypeOrmModule.forFeature([KeyValuePair, UserWorkspace], 'core'),
UserVarsModule,
AnalyticsModule,
DomainManagerModule,
UserRoleModule,
FeatureFlagModule,
],
exports: [UserService],
providers: [UserService, UserResolver, TypeORMService],
Expand Down
100 changes: 84 additions & 16 deletions packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,45 @@ import crypto from 'crypto';

import { GraphQLJSONObject } from 'graphql-type-json';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared';
import { In, Repository } from 'typeorm';

import { SupportDriver } from 'src/engine/core-modules/environment/interfaces/support.interface';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';

import { AnalyticsService } from 'src/engine/core-modules/analytics/analytics.service';
import { AnalyticsTinybirdJwtMap } from 'src/engine/core-modules/analytics/entities/analytics-tinybird-jwts.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
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 { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import {
OnboardingService,
OnboardingStepKeys,
} from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { streamToBuffer } from 'src/utils/stream-to-buffer';

const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null;
Expand All @@ -70,6 +75,10 @@ export class UserResolver {
private readonly fileService: FileService,
private readonly analyticsService: AnalyticsService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly userRoleService: UserRoleService,
private readonly featureFlagService: FeatureFlagService,
) {}

@Query(() => User)
Expand Down Expand Up @@ -159,22 +168,81 @@ export class UserResolver {
@Parent() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<WorkspaceMember[]> {
const workspaceMembers =
const workspaceMemberEntities =
await this.userService.loadWorkspaceMembers(workspace);

for (const workspaceMember of workspaceMembers) {
if (workspaceMember.avatarUrl) {
const workspaceMembers: WorkspaceMember[] = [];

const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
workspaceId: workspace.id,
},
});

const userWorkspacesByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace,
]),
);

for (const workspaceMemberEntity of workspaceMemberEntities) {
if (workspaceMemberEntity.avatarUrl) {
const avatarUrlToken = await this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id,
workspaceMemberId: workspaceMemberEntity.id,
workspaceId: workspace.id,
});

workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`;
}

const userWorkspace = userWorkspacesByUserId.get(
workspaceMemberEntity.userId,
);

if (!userWorkspace) {
throw new Error('User workspace not found');
}

const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
);

const workspaceMember: WorkspaceMember = {
...workspaceMemberEntity,
userWorkspaceId: userWorkspace.id,
} as WorkspaceMember;

if (permissionsEnabled === true) {
const roles = await this.userRoleService
.getRolesForUserWorkspace(userWorkspace.id)
.then(([roleEntity]) => {
if (!isDefined(roleEntity)) {
return [];
}

return [
{
id: roleEntity.id,
label: roleEntity.label,
canUpdateAllSettings: roleEntity.canUpdateAllSettings,
description: roleEntity.description,
isEditable: roleEntity.isEditable,
userWorkspaceRoles: roleEntity.userWorkspaceRoles,
},
];
});

workspaceMember.roles = roles;
}

workspaceMembers.push(workspaceMember);
}

// TODO: Fix typing disrepency between Entity and DTO
return workspaceMembers as WorkspaceMember[];
return workspaceMembers;
}

@ResolveField(() => String, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ export enum PermissionsExceptionCode {
TOO_MANY_ADMIN_CANDIDATES = 'TOO_MANY_ADMIN_CANDIDATES',
USER_WORKSPACE_ALREADY_HAS_ROLE = 'USER_WORKSPACE_ALREADY_HAS_ROLE',
PERMISSION_DENIED = 'PERMISSION_DENIED',
WORKSPACE_MEMBER_NOT_FOUND = 'WORKSPACE_MEMBER_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN',
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
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';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';

@Module({
imports: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/userRole/userRole.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';

@Injectable()
export class PermissionsService {
Expand All @@ -21,8 +21,8 @@ export class PermissionsService {
}: {
userWorkspaceId: string;
}): Promise<Record<SettingsFeatures, boolean>> {
const roleOfUserWorkspace =
await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId);
const [roleOfUserWorkspace] =
await this.userRoleService.getRolesForUserWorkspace(userWorkspaceId);

let hasPermissionOnSettingFeature = false;

Expand All @@ -46,8 +46,8 @@ export class PermissionsService {
userWorkspaceId: string;
setting: SettingsFeatures;
}): Promise<void> {
const userWorkspaceRole =
await this.userRoleService.getRoleForUserWorkspace(userWorkspaceId);
const [userWorkspaceRole] =
await this.userRoleService.getRolesForUserWorkspace(userWorkspaceId);

if (userWorkspaceRole?.canUpdateAllSettings === true) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const permissionsGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof PermissionsException) {
switch (error.code) {
case PermissionsExceptionCode.PERMISSION_DENIED:
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
throw new ForbiddenError(error.message);
default:
throw new InternalServerError(error.message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

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 { 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';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';

@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'metadata'),
TypeOrmModule.forFeature([UserWorkspace], 'core'),
UserRoleModule,
PermissionsModule,
UserWorkspaceModule,
],
providers: [RoleService, RoleResolver],
exports: [RoleService],
Expand Down
Loading

0 comments on commit a3bb8e0

Please sign in to comment.