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:
+53
@@ -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,
|
||||
|
||||
+63
@@ -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();
|
||||
};
|
||||
+6
@@ -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];
|
||||
|
||||
+12
@@ -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];
|
||||
|
||||
+21
-3
@@ -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],
|
||||
|
||||
+13
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user