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:
+91
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -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
@@ -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],
|
||||
|
||||
+15
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user