[permissions] forbid deletion of last admin user (#10504)

A user should not be able to delete their account if they are the last
admin of a workspace.

It means that if a user wants to sign out of twenty, they should delete
their workspace, not their account
This commit is contained in:
Marie
2025-02-27 12:44:51 +01:00
committed by GitHub
parent fb38828943
commit 17dbb634ca
8 changed files with 158 additions and 65 deletions

View File

@ -1,62 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
},
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useValue: {},
},
{
provide: getRepositoryToken(ObjectMetadataEntity, 'metadata'),
useValue: {},
},
{
provide: DataSourceService,
useValue: {},
},
{
provide: TypeORMService,
useValue: {},
},
{
provide: WorkspaceEventEmitter,
useValue: {},
},
{
provide: WorkspaceService,
useValue: {},
},
{
provide: TwentyORMGlobalManager,
useValue: {},
},
],
}).compile();
service = module.get<UserService>(UserService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -12,12 +12,21 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
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 { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
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 {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
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';
@ -34,6 +43,9 @@ export class UserService extends TypeOrmQueryService<User> {
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspaceService: WorkspaceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly userRoleService: UserRoleService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly featureFlagService: FeatureFlagService,
) {
super(userRepository);
}
@ -85,9 +97,28 @@ export class UserService extends TypeOrmQueryService<User> {
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspaceId,
);
const workspaceMembers = await workspaceDataSource?.query(
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember"`,
);
if (isPermissionsEnabled && workspaceMembers.length > 1) {
const userWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId,
workspaceId,
});
await this.userRoleService.validateUserWorkspaceIsNotUniqueAdminOrThrow({
workspaceId,
userWorkspaceId: userWorkspace.id,
});
}
const workspaceMember = workspaceMembers.filter(
(member: WorkspaceMemberWorkspaceEntity) => member.userId === userId,
)?.[0];
@ -138,7 +169,25 @@ export class UserService extends TypeOrmQueryService<User> {
userValidator.assertIsDefinedOrThrow(user);
await Promise.all(
user.workspaces.map(this.deleteUserFromWorkspace.bind(this)),
user.workspaces.map(async (userWorkspace) => {
try {
await this.deleteUserFromWorkspace({
userId,
workspaceId: userWorkspace.workspaceId,
});
} catch (error: any) {
if (
error instanceof PermissionsException &&
error.code === PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN
) {
throw new PermissionsException(
PermissionsExceptionMessage.CANNOT_DELETE_LAST_ADMIN_USER,
PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
);
}
throw error;
}
}),
);
return user;

View File

@ -15,6 +15,7 @@ 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 { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
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';
@ -50,6 +51,7 @@ import { UserService } from './services/user.service';
UserRoleModule,
FeatureFlagModule,
PermissionsModule,
UserWorkspaceModule,
],
exports: [UserService],
providers: [UserService, UserResolver, TypeORMService],

View File

@ -1,4 +1,4 @@
import { UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards } from '@nestjs/common';
import {
Args,
Mutation,
@ -50,6 +50,7 @@ import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
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';
@ -65,6 +66,7 @@ const getHMACKey = (email?: string, key?: string | null) => {
@UseGuards(WorkspaceAuthGuard)
@Resolver(() => User)
@UseFilters(PermissionsGraphqlApiExceptionFilter)
export class UserResolver {
constructor(
@InjectRepository(User, 'core')