Fix user deletion flows (#15614)

**Before**
- any user with workpace_members permission was able to remove a user
from their workspace. This triggered the deletion of workspaceMember +
of userWorkspace, but did not delete the user (even if they had no
workspace left) nor the roleTarget (acts as junction between role and
userWorkspace) which was left with a userWorkspaceId pointing to
nothing. This is because roleTarget points to userWorkspaceId but the
foreign key constraint was not implemented
- any user could delete their own account. This triggered the deletion
of all their workspaceMembers, but not of their userWorkspace nor their
user nor the roleTarget --> we have orphaned userWorkspace, not
technically but product wise - a userWorkspace without a workspaceMember
does not make sense

So the problems are
- we have some roleTargets pointing to non-existing userWorkspaceId
(which caused https://github.com/twentyhq/twenty/issues/14608 )
- we have userWorkspaces that should not exist and that have no
workspaceMember counterpart
- it is not possible for a user to leave a workspace by themselves, they
can only leave all workspaces at once, except if they are being removed
from the workspace by another user

**Now**
- if a user has multiple workspaces, they are given the possibility to
leave one workspace while remaining in the others (we show two buttons:
Leave workspace and Delete account buttons). if a user has just one
workspace, they only see Delete account
- when a user leaves a workspace, we delete their workspaceMember,
userWorkspace and roleTarget. If they don't belong to any other
workspace we also soft-delete their user
- soft-deleted users get hard deleted after 30 days thanks to a cron
- we have two commands to clean the orphans roleTarget and userWorkspace
(TODO: query db to see how many must be run)

**Next**
- once the commands have been run, we can implement and introduce the
foreign key constraint on roleTarget


Fixes https://github.com/twentyhq/twenty/issues/14608
This commit is contained in:
Marie
2025-11-06 19:29:12 +01:00
committed by GitHub
parent bfe1f47065
commit 4ce93aee52
38 changed files with 980 additions and 173 deletions
@@ -1,16 +1,12 @@
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
@WorkspaceQueryHook(`workspaceMember.deleteMany`)
export class WorkspaceMemberDeleteManyPreQueryHook
@@ -18,14 +14,10 @@ export class WorkspaceMemberDeleteManyPreQueryHook
{
constructor() {}
async execute(authContext: AuthContext): Promise<DeleteManyResolverArgs> {
const workspace = authContext.workspace;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
async execute(_authContext: AuthContext): Promise<DeleteManyResolverArgs> {
throw new CommonQueryRunnerException(
'Please use /deleteUserFromWorkspace to remove a workspace member.',
CommonQueryRunnerExceptionCode.BAD_REQUEST,
);
}
}
@@ -9,6 +9,7 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import {
PermissionsException,
@@ -30,6 +31,7 @@ export class WorkspaceMemberDeleteOnePostQueryHook
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
private readonly userWorkspaceService: UserWorkspaceService,
) {}
async execute(
@@ -91,6 +93,8 @@ export class WorkspaceMemberDeleteOnePostQueryHook
);
}
await this.userWorkspaceRepository.delete(userWorkspace.id);
await this.userWorkspaceService.deleteUserWorkspace({
userWorkspaceId: userWorkspace.id,
});
}
}
@@ -0,0 +1,23 @@
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@WorkspaceQueryHook(`workspaceMember.deleteOne`)
export class WorkspaceMemberDeleteOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor() {}
async execute(_authContext: AuthContext): Promise<RestoreOneResolverArgs> {
throw new CommonQueryRunnerException(
'Please use /deleteUserFromWorkspace to remove a workspace member.',
CommonQueryRunnerExceptionCode.BAD_REQUEST,
);
}
}
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } 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 { WorkspaceMemberCreateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook';
import { WorkspaceMemberCreateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook';
import { WorkspaceMemberDeleteManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook';
import { WorkspaceMemberDeleteOnePostQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook';
import { WorkspaceMemberDeleteOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook';
import { WorkspaceMemberDestroyManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook';
import { WorkspaceMemberDestroyOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook';
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
@@ -21,6 +23,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
WorkspaceMemberPreQueryHookService,
WorkspaceMemberCreateOnePreQueryHook,
WorkspaceMemberCreateManyPreQueryHook,
WorkspaceMemberDeleteOnePreQueryHook,
WorkspaceMemberDeleteOnePostQueryHook,
WorkspaceMemberDeleteManyPreQueryHook,
WorkspaceMemberDestroyOnePreQueryHook,
@@ -33,6 +36,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
imports: [
FeatureFlagModule,
PermissionsModule,
UserWorkspaceModule,
TypeOrmModule.forFeature([UserWorkspaceEntity]),
],
})