Centralize and standardize impersonation validation rules (#21717)

# Introduction
Followup https://github.com/twentyhq/twenty/pull/21707

## Behavioral change worth calling out
Server-level impersonation now requires verified 2FA outside development
at every checkpoint (generation, exchange, and per-request). In main the
2FA gate only existed in ImpersonationService. This is the right
tightening, but it means existing server-admin impersonation sessions in
production for admins without verified 2FA will now be rejected on the
next request, not just at token creation.

cc @s0yd4RK

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21717?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: s0yd4RK <285671363+s0yd4RK@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paul Rastoin
2026-06-18 13:13:50 +02:00
committed by GitHub
parent 465eb05aaf
commit e7e99247e8
50 changed files with 1510 additions and 269 deletions
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { DataSource, Repository } from 'typeorm';
@@ -9,6 +10,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { RolePermissionFlagService } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -41,6 +43,7 @@ export class DevSeederPermissionsService {
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
private readonly fieldPermissionService: FieldPermissionService,
private readonly roleTargetService: RoleTargetService,
private readonly rolePermissionFlagService: RolePermissionFlagService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {}
@@ -126,6 +129,18 @@ export class DevSeederPermissionsService {
userWorkspaceIds: [limitedUserWorkspaceId],
roleId: limitedRole.id,
});
const impersonateOnlyRole =
await this.createImpersonateOnlyRoleForSeedWorkspace({
workspaceId,
ownerFlatApplication: workspaceCustomFlatApplication,
});
await this.userRoleService.assignRoleToManyUserWorkspace({
workspaceId,
userWorkspaceIds: [USER_WORKSPACE_DATA_SEED_IDS.SCOTT],
roleId: impersonateOnlyRole.id,
});
}
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
adminUserWorkspaceId = USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME;
@@ -184,6 +199,44 @@ export class DevSeederPermissionsService {
return memberRole;
}
// Creates a non-admin role whose only elevated capability is the workspace
// IMPERSONATE permission flag. Assigned to Scott so the impersonation
// escalation guard can be exercised: a non-admin holding IMPERSONATE must
// still be blocked from impersonating an admin.
private async createImpersonateOnlyRoleForSeedWorkspace({
ownerFlatApplication,
workspaceId,
}: {
workspaceId: string;
ownerFlatApplication: FlatApplication;
}): Promise<RoleDTO> {
const impersonateOnlyRole = await this.roleService.createRole({
ownerFlatApplication,
workspaceId,
input: {
label: 'Impersonate-only',
description: 'Member role granted only the impersonate permission',
icon: 'IconSpy',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
},
});
await this.rolePermissionFlagService.upsertPermissionFlags({
workspaceId,
input: {
roleId: impersonateOnlyRole.id,
permissionFlagKeys: [PermissionFlagType.IMPERSONATE],
},
});
return impersonateOnlyRole;
}
private async createLimitedRoleForSeedWorkspace({
ownerFlatApplication,
workspaceId,
@@ -0,0 +1,63 @@
import { type QueryRunner } from 'typeorm';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
const tableName = 'twoFactorAuthenticationMethod';
export const TWO_FACTOR_AUTHENTICATION_METHOD_DATA_SEED_IDS = {
JANE: '20202020-1111-4a01-8001-000000000004',
};
type SeedTwoFactorAuthenticationMethodsArgs = {
queryRunner: QueryRunner;
schemaName: string;
workspaceId: string;
// Real enc:v2 envelope encrypted for this workspace so the value survives the
// secret-encryption rotation command (which decrypts/re-encrypts every
// `totp-secret` row); a malformed placeholder would break that rotation.
encryptedSecret: string;
};
// Seeds a verified TOTP method for Jane so the server-level impersonation flow
// (which requires verified 2FA outside development) can be tested end to end.
// Gated to the test environment and Apple workspace: only the VERIFIED status
// is read by the impersonation check, and seeding it in the dev/demo workspace
// would otherwise make Jane unable to complete a real 2FA login.
export const seedTwoFactorAuthenticationMethods = async ({
queryRunner,
schemaName,
workspaceId,
encryptedSecret,
}: SeedTwoFactorAuthenticationMethodsArgs) => {
if (
process.env.NODE_ENV !== 'test' ||
workspaceId !== SEED_APPLE_WORKSPACE_ID
) {
return;
}
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.${tableName}`, [
'id',
'workspaceId',
'userWorkspaceId',
'secret',
'status',
'strategy',
])
.orIgnore()
.values([
{
id: TWO_FACTOR_AUTHENTICATION_METHOD_DATA_SEED_IDS.JANE,
workspaceId,
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE,
secret: encryptedSecret,
status: 'VERIFIED',
strategy: 'TOTP',
},
])
.execute();
};
@@ -19,6 +19,7 @@ export const USER_WORKSPACE_DATA_SEED_IDS = {
TIM_ACME: '20202020-e10a-4c27-a90b-b08c57b02d44',
JONY_ACME: '20202020-e10a-4c27-a90b-b08c57b02d45',
PHIL_ACME: '20202020-e10a-4c27-a90b-b08c57b02d46',
SCOTT: '20202020-1111-4a01-8001-000000000002',
};
const {
@@ -66,6 +67,11 @@ export const seedUserWorkspaces = async ({
userId: USER_DATA_SEED_IDS.PHIL,
workspaceId,
},
{
id: USER_WORKSPACE_DATA_SEED_IDS.SCOTT,
userId: USER_DATA_SEED_IDS.SCOTT,
workspaceId,
},
];
userWorkspaces = [...originalUserWorkspaces, ...randomUserWorkspaces];
@@ -9,6 +9,7 @@ export const USER_DATA_SEED_IDS = {
TIM: '20202020-9e3b-46d4-a556-88b9ddc2b034',
JONY: '20202020-3957-4908-9c36-2929a23f8357',
PHIL: '20202020-7169-42cf-bc47-1cfef15264b8',
SCOTT: '20202020-1111-4a01-8001-000000000001',
};
const { users: randomUsers, userIds: randomUserIds } = generateRandomUsers();
@@ -66,6 +67,17 @@ export const seedUsers = async ({ queryRunner, schemaName }: SeedUsersArgs) => {
canAccessFullAdminPanel: true,
isEmailVerified: true,
},
{
id: USER_DATA_SEED_IDS.SCOTT,
firstName: 'Scott',
lastName: 'Forstall',
email: 'scott.forstall@apple.dev',
passwordHash:
'$2b$10$3LwXjJRtLsfx4hLuuXhxt.3mWgismTiZFCZSG3z9kDrSfsrBl0fT6', // tim@apple.dev
canImpersonate: false,
canAccessFullAdminPanel: false,
isEmailVerified: true,
},
];
const allUsers = [...originalUsers, ...randomUsers];
@@ -31,6 +31,7 @@ export const WORKSPACE_MEMBER_DATA_SEED_IDS = {
JONY: '20202020-77d5-4cb6-b60a-f4a835a85d61',
PHIL: '20202020-1553-45c6-a028-5a9064cce07f',
JANE: '20202020-463f-435b-828c-107e007a2711',
SCOTT: '20202020-1111-4a01-8001-000000000003',
};
const {
@@ -79,6 +80,20 @@ const originalWorkspaceMembers: WorkspaceMemberDataSeed[] = [
},
];
// Scott only belongs to the Apple workspace (he has no YCombinator
// user-workspace), so he must never leak into other workspaces' member seeds.
const appleOnlyWorkspaceMembers: WorkspaceMemberDataSeed[] = [
{
id: WORKSPACE_MEMBER_DATA_SEED_IDS.SCOTT,
nameFirstName: 'Scott',
nameLastName: 'Forstall',
locale: 'en',
colorScheme: 'Light',
userEmail: 'scott.forstall@apple.dev',
userId: USER_DATA_SEED_IDS.SCOTT,
},
];
export const WORKSPACE_MEMBER_DATA_SEEDS: WorkspaceMemberDataSeed[] = [
...originalWorkspaceMembers,
...randomWorkspaceMembers,
@@ -88,13 +103,16 @@ export const getWorkspaceMemberDataSeeds = (
workspaceId: string,
): WorkspaceMemberDataSeed[] => {
// In test environment, only return original members to avoid conflicts
// (Scott is appended for Apple to back the impersonation escalation test).
if (process.env.NODE_ENV === 'test') {
return originalWorkspaceMembers;
return workspaceId === SEED_APPLE_WORKSPACE_ID
? [...originalWorkspaceMembers, ...appleOnlyWorkspaceMembers]
: originalWorkspaceMembers;
}
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
// Apple workspace gets all workspace members (original + random)
return WORKSPACE_MEMBER_DATA_SEEDS;
// Apple workspace gets all workspace members (original + random + Scott)
return [...WORKSPACE_MEMBER_DATA_SEEDS, ...appleOnlyWorkspaceMembers];
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
// YC workspace gets all 4 original workspace members
return originalWorkspaceMembers;
@@ -7,6 +7,7 @@ import { ApplicationRegistrationModule } from 'src/engine/core-modules/applicati
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
@@ -14,6 +15,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { RolePermissionFlagModule } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.module';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
@@ -40,6 +42,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceCacheStorageModule,
TypeORMModule,
RoleModule,
RolePermissionFlagModule,
RoleTargetModule,
UserRoleModule,
ApiKeyModule,
@@ -55,6 +58,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceMigrationModule,
TwentyStandardApplicationModule,
SdkClientModule,
SecretEncryptionModule,
UpgradeModule,
],
exports: [DevSeederService],
@@ -7,6 +7,8 @@ import { v4 } from 'uuid';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
@@ -33,6 +35,7 @@ import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/u
import { seedMetadataEntities } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-metadata-entities.util';
import { seedPageLayouts } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layouts.util';
import { seedServerId } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-server-id.util';
import { seedTwoFactorAuthenticationMethods } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util';
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
import { createWorkspace } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util';
@@ -64,6 +67,7 @@ export class DevSeederService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly prefillFrontComponentService: PrefillFrontComponentService,
private readonly prefillLogicFunctionService: PrefillLogicFunctionService,
private readonly secretEncryptionService: SecretEncryptionService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
@InjectRepository(WorkspaceEntity)
@@ -310,6 +314,15 @@ export class DevSeederService {
await seedServerId({ queryRunner, schemaName });
await seedUsers({ queryRunner, schemaName });
await seedUserWorkspaces({ queryRunner, schemaName, workspaceId });
await seedTwoFactorAuthenticationMethods({
queryRunner,
schemaName,
workspaceId,
encryptedSecret: this.secretEncryptionService.encryptVersioned(
'seed-totp-secret-test-fixture' as PlaintextString,
{ workspaceId },
),
});
await this.applicationService.createTwentyStandardApplication(
{