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
@@ -0,0 +1,91 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { In, type Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade:1-11:clean-orphaned-role-targets',
description:
'Clean up roleTargets records that reference non-existent userWorkspaces',
})
export class CleanOrphanedRoleTargetsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
private hasRunOnce = false;
constructor(
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
this.logger.log('This command has already been run');
return;
}
const isDryRun = options.dryRun || false;
if (isDryRun) {
this.logger.log('Dry run mode: No changes will be applied');
}
const orphanedRoleTargets = await this.roleTargetsRepository
.createQueryBuilder('roleTarget')
.leftJoin(
UserWorkspaceEntity,
'userWorkspace',
'userWorkspace.id = roleTarget.userWorkspaceId',
)
.where('roleTarget.userWorkspaceId IS NOT NULL')
.andWhere('userWorkspace.id IS NULL')
.select([
'roleTarget.id',
'roleTarget.userWorkspaceId',
'roleTarget.workspaceId',
])
.getMany();
if (orphanedRoleTargets.length === 0) {
this.logger.log('No orphaned roleTargets found');
return;
}
if (isDryRun) {
this.logger.log(
`DRY RUN: Would delete ${orphanedRoleTargets.length} orphaned roleTarget(s):`,
);
orphanedRoleTargets.forEach((roleTarget) => {
this.logger.log(
` - roleTargetId: ${roleTarget.id}, userWorkspaceId: ${roleTarget.userWorkspaceId}, workspaceId: ${roleTarget.workspaceId}`,
);
});
return;
}
const orphanedIds = orphanedRoleTargets.map((roleTarget) => roleTarget.id);
await this.roleTargetsRepository.delete({ id: In(orphanedIds) });
this.logger.log(
`Deleted ${orphanedRoleTargets.length} orphaned roleTarget(s)`,
);
this.hasRunOnce = true;
}
}
@@ -0,0 +1,99 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { In, type Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.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';
@Command({
name: 'upgrade:1-11:clean-orphaned-user-workspaces',
description:
'Clean up userWorkspace records that do not have a corresponding workspaceMember in the workspace schema',
})
export class CleanOrphanedUserWorkspacesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
workspaceId,
},
});
if (userWorkspaces.length === 0) {
this.logger.log(
`No userWorkspaces found for workspace ${workspaceId}, skipping`,
);
return;
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers = await workspaceMemberRepository.find({
select: ['userId'],
});
const workspaceMemberUserIds = new Set(
workspaceMembers.map((member) => member.userId),
);
const orphanedUserWorkspaces = userWorkspaces.filter(
(userWorkspace) => !workspaceMemberUserIds.has(userWorkspace.userId),
);
if (orphanedUserWorkspaces.length === 0) {
this.logger.log(
`No orphaned userWorkspaces found for workspace ${workspaceId}`,
);
return;
}
if (isDryRun) {
this.logger.log(
`DRY RUN: Would delete ${orphanedUserWorkspaces.length} orphaned userWorkspace(s) for workspace ${workspaceId}:`,
);
orphanedUserWorkspaces.forEach((userWorkspace) => {
this.logger.log(
` - userWorkspaceId: ${userWorkspace.id}, userId: ${userWorkspace.userId}`,
);
});
return;
}
// Delete orphaned userWorkspaces
const orphanedIds = orphanedUserWorkspaces.map((uw) => uw.id);
await this.userWorkspaceRepository.delete({ id: In(orphanedIds) });
this.logger.log(
`Deleted ${orphanedUserWorkspaces.length} orphaned userWorkspace(s) for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command';
import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
@Module({
imports: [
TypeOrmModule.forFeature([
WorkspaceEntity,
ObjectMetadataEntity,
FieldMetadataEntity,
IndexMetadataEntity,
ViewEntity,
UserWorkspaceEntity,
RoleTargetsEntity,
]),
WorkspaceSchemaManagerModule,
],
providers: [
CleanOrphanedUserWorkspacesCommand,
CleanOrphanedRoleTargetsCommand,
],
exports: [
CleanOrphanedUserWorkspacesCommand,
CleanOrphanedRoleTargetsCommand,
],
})
export class V1_11_UpgradeVersionCommandModule {}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { V1_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module';
import { V1_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module';
import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module';
import { V1_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module';
import { V1_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-8/1-8-upgrade-version-command.module';
@@ -16,6 +17,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
V1_7_UpgradeVersionCommandModule,
V1_8_UpgradeVersionCommandModule,
V1_10_UpgradeVersionCommandModule,
V1_11_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule,
],
providers: [UpgradeCommand],
@@ -19,6 +19,8 @@ import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/command
import { MigrateChannelPartialFullSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-channel-partial-full-sync-stages.command';
import { RegenerateSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-search-vectors.command';
import { SeedDashboardViewCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-seed-dashboard-view.command';
import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command';
import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
@@ -69,6 +71,10 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly seedDashboardViewCommand: SeedDashboardViewCommand,
protected readonly createViewKanbanFieldMetadataIdForeignKeyMigrationCommand: CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
protected readonly flushWorkspaceCacheCommand: FlushCacheCommand,
// 1.11 Commands
protected readonly cleanOrphanedUserWorkspacesCommand: CleanOrphanedUserWorkspacesCommand,
protected readonly cleanOrphanedRoleTargetsCommand: CleanOrphanedRoleTargetsCommand,
) {
super(
workspaceRepository,
@@ -117,11 +123,20 @@ export class UpgradeCommand extends UpgradeCommandRunner {
],
};
const commands_1110: VersionCommands = {
beforeSyncMetadata: [],
afterSyncMetadata: [
this.cleanOrphanedUserWorkspacesCommand,
this.cleanOrphanedRoleTargetsCommand,
],
};
this.allCommands = {
'1.6.0': commands_160,
'1.7.0': commands_170,
'1.8.0': commands_180,
'1.10.0': commands_1100,
'1.11.0': commands_1110,
};
}