From e7e99247e85d6d80899d3448743dce69b27ff245 Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:13:50 +0200
Subject: [PATCH] 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
---------
Co-authored-by: s0yd4RK <285671363+s0yd4RK@users.noreply.github.com>
Co-authored-by: Cursor
---
.../engine/core-modules/auth/auth.module.ts | 12 +-
.../core-modules/auth/auth.resolver.spec.ts | 5 +
.../engine/core-modules/auth/auth.resolver.ts | 92 ++--
.../auth/strategies/jwt.auth.strategy.spec.ts | 14 +-
.../auth/strategies/jwt.auth.strategy.ts | 77 +---
.../core-modules/auth/token/token.module.ts | 2 +
...auth-graphql-api-exception-handler.util.ts | 1 +
...mpersonation-authorization.service.spec.ts | 411 ++++++++++++++++++
.../__tests__/impersonation.service.spec.ts | 14 +-
...impersonation-denial-by-reason.constant.ts | 47 ++
...n-denial-log-message-by-reason.constant.ts | 23 +
.../impersonation-authorization.module.ts | 11 +
.../impersonation/impersonation.module.ts | 4 +-
.../impersonation/impersonation.resolver.ts | 10 +-
.../impersonation-authorization.service.ts | 129 ++++++
.../services/impersonation.service.ts | 95 +---
.../user-can-server-impersonate.util.spec.ts | 18 +
.../user-has-admin-privileges.util.spec.ts | 30 ++
.../__tests__/user-is-full-admin.util.spec.ts | 18 +
.../impersonation-authorization-user.type.ts | 4 +
.../core-modules/impersonation/utils/index.ts | 4 +
.../utils/user-can-server-impersonate.util.ts | 5 +
.../utils/user-has-admin-privileges.util.ts | 7 +
.../utils/user-is-full-admin.util.ts | 5 +
.../src/engine/guards/admin-panel-guard.ts | 4 +-
.../admin-panel-or-impersonate.guard.ts | 7 +-
.../guards/impersonate-permission.guard.ts | 5 +-
.../guards/server-level-impersonate.guard.ts | 4 +-
.../dev-seeder-permissions.service.ts | 53 +++
...-two-factor-authentication-methods.util.ts | 63 +++
.../core/utils/seed-user-workspaces.util.ts | 6 +
.../dev-seeder/core/utils/seed-users.util.ts | 12 +
.../workspace-member-data-seeds.constant.ts | 24 +-
.../dev-seeder/dev-seeder.module.ts | 4 +
.../dev-seeder/services/dev-seeder.service.ts | 13 +
...tion-access-token.integration-spec.ts.snap | 15 +
...elf-impersonation.integration-spec.ts.snap | 13 +
...ted-impersonation.integration-spec.ts.snap | 11 +
...rsonation-access-token.integration-spec.ts | 29 ++
...ing-self-impersonation.integration-spec.ts | 18 +
...nticated-impersonation.integration-spec.ts | 32 ++
...ver-impersonation.integration-spec.ts.snap | 25 ++
...g-server-impersonation.integration-spec.ts | 40 ++
...l-server-impersonation.integration-spec.ts | 58 +++
...ace-impersonation.integration-spec.ts.snap | 25 ++
...orkspace-impersonation.integration-spec.ts | 44 ++
...orkspace-impersonation.integration-spec.ts | 57 +++
.../failing-impersonation.integration-spec.ts | 47 --
.../get-access-token-for-credentials.util.ts | 67 +++
.../graphql/utils/impersonate.util.ts | 65 +++
50 files changed, 1510 insertions(+), 269 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts
create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts
delete mode 100644 packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts
create mode 100644 packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
index 437cbc56a1..0b9b7f3744 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
@@ -1,16 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
-import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
+import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
-import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller';
+import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
-import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
@@ -40,10 +40,12 @@ import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
+import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
+import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
@@ -59,7 +61,6 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
-import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
@@ -68,8 +69,8 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
-import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
+import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
@@ -109,6 +110,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
WorkspaceInvitationModule,
EmailVerificationModule,
GuardRedirectModule,
+ ImpersonationAuthorizationModule,
MetricsModule,
PermissionsModule,
TwoFactorAuthenticationModule,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
index e8fc92398e..154b7c2873 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts
@@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
@@ -120,6 +121,10 @@ describe('AuthResolver', () => {
provide: EmailVerificationTokenService,
useValue: {},
},
+ {
+ provide: ImpersonationAuthorizationService,
+ useValue: {},
+ },
{
provide: PermissionsService,
useValue: {},
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
index 91a043c228..665b48e9a9 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
@@ -60,6 +60,9 @@ import { EmailVerificationService } from 'src/engine/core-modules/email-verifica
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
+import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant';
+import { IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input';
import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter';
@@ -80,7 +83,6 @@ import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
-import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { ApiKeyToken } from './dto/api-key-token.dto';
@@ -132,7 +134,7 @@ export class AuthResolver {
private emailVerificationTokenService: EmailVerificationTokenService,
private ssoService: SSOService,
private readonly eventLogEmitterService: EventLogEmitterService,
- private readonly permissionsService: PermissionsService,
+ private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
private readonly subdomainManagerService: SubdomainManagerService,
) {}
@@ -702,7 +704,7 @@ export class AuthResolver {
const impersonatorUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: impersonatorUserWorkspaceId },
- relations: ['user', 'workspace'],
+ relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'],
});
const toImpersonateUserWorkspace =
@@ -733,75 +735,49 @@ export class AuthResolver {
);
}
- const isServerLevelImpersonation =
- toImpersonateUserWorkspace.workspace.id !==
- impersonatorUserWorkspace.workspace.id;
-
const eventLogContext = this.eventLogEmitterService.createContext({
workspaceId: workspace.id,
userId: impersonatorUserWorkspace.user.id,
});
+ const impersonationLevel =
+ this.impersonationAuthorizationService.getImpersonationLevel(
+ impersonatorUserWorkspace,
+ toImpersonateUserWorkspace,
+ );
+
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
- level: isServerLevelImpersonation ? 'server' : 'workspace',
+ level: impersonationLevel,
action: 'token_exchange_attempt',
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
});
- const hasServerLevelImpersonatePermission =
- impersonatorUserWorkspace.user.canImpersonate === true &&
- toImpersonateUserWorkspace.workspace.allowImpersonation === true;
-
- if (isServerLevelImpersonation) {
- if (!hasServerLevelImpersonatePermission) {
- void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
- level: 'server',
- action: 'token_exchange_failed',
- message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
- });
-
- throw new AuthException(
- 'Server level impersonation not allowed on this workspace',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
- }
-
- void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
- level: 'server',
- action: 'token_exchange_success',
- message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
- });
-
- return {
- workspaceId: workspace.id,
- impersonatorUserWorkspaceId: impersonatorUserWorkspace.id,
- impersonatedUserWorkspaceId: toImpersonateUserWorkspace.id,
- impersonatorUserId: impersonatorUserWorkspace.user.id,
- impersonatedUserId: toImpersonateUserWorkspace.user.id,
- };
- }
-
- const hasWorkspaceLevelImpersonatePermission =
- await this.permissionsService.userHasWorkspaceSettingPermission({
- userWorkspaceId: impersonatorUserWorkspace.id,
- setting: PermissionFlagType.IMPERSONATE,
- workspaceId: workspace.id,
- });
-
- if (!hasWorkspaceLevelImpersonatePermission) {
- void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
- level: 'workspace',
- action: 'token_exchange_failed',
- message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
- });
- throw new AuthException(
- 'Impersonation not allowed',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ const authorizationResult =
+ await this.impersonationAuthorizationService.checkImpersonationAuthorization(
+ impersonatorUserWorkspace,
+ toImpersonateUserWorkspace,
);
+
+ if (!authorizationResult.allowed) {
+ void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
+ level: authorizationResult.level,
+ action: 'token_exchange_failed',
+ message: IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON[
+ authorizationResult.reason
+ ]({
+ targetUserEmail,
+ impersonatorUserId: impersonatorUserWorkspace.user.id,
+ }),
+ });
+
+ const { message, exceptionCode, userFriendlyMessage } =
+ IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason];
+
+ throw new AuthException(message, exceptionCode, { userFriendlyMessage });
}
void eventLogContext.insertWorkspaceEvent(IMPERSONATION_EVENT, {
- level: 'workspace',
+ level: authorizationResult.level,
action: 'token_exchange_success',
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
index b9cd385869..e4217f52e1 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts
@@ -8,6 +8,8 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
+import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -17,6 +19,7 @@ describe('JwtAuthStrategy', () => {
let userWorkspaceRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
+ let twentyConfigService: any;
let workspaceCacheService: any;
let coreEntityCacheService: any;
@@ -52,6 +55,12 @@ describe('JwtAuthStrategy', () => {
userHasWorkspaceSettingPermission: jest.fn(),
};
+ twentyConfigService = {
+ get: jest.fn((key: string) =>
+ key === 'NODE_ENV' ? NodeEnvironment.DEVELOPMENT : undefined,
+ ),
+ };
+
workspaceCacheService = {
getOrRecompute: jest.fn(
async (workspaceId: string, cacheKeys: string[]) => {
@@ -118,9 +127,12 @@ describe('JwtAuthStrategy', () => {
new JwtAuthStrategy(
jwtWrapperService,
userWorkspaceRepository,
- permissionsService,
workspaceCacheService,
coreEntityCacheService,
+ new ImpersonationAuthorizationService(
+ permissionsService,
+ twentyConfigService,
+ ),
);
describe('API_KEY validation', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
index ca2fd73e57..20cf479486 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts
@@ -4,32 +4,32 @@ import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { type SecretOrKeyProvider, Strategy } from 'passport-jwt';
-import { PermissionFlagType } from 'twenty-shared/constants';
-import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
-import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
-import { Repository } from 'typeorm';
+import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
+import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
+import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type';
+import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import {
type AuthContext,
type AuthContextUser,
} from 'src/engine/core-modules/auth/types/auth-context.type';
-import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
-import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type';
-import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
-import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
-import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
+import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
+import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
-import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
+import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
+import { Repository } from 'typeorm';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -37,9 +37,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository,
- private readonly permissionsService: PermissionsService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly coreEntityCacheService: CoreEntityCacheService,
+ private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
) {
const secretOrKeyProvider: SecretOrKeyProvider = (
_request,
@@ -272,7 +272,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
const impersonatorUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: payload.impersonatorUserWorkspaceId },
- relations: ['user', 'workspace'],
+ relations: ['user', 'workspace', 'twoFactorAuthenticationMethods'],
});
const impersonatedUserWorkspace =
@@ -291,54 +291,17 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
- const isServerLevelImpersonation =
- impersonatorUserWorkspace.workspace.id !==
- impersonatedUserWorkspace.workspace.id;
-
- const hasServerLevelImpersonatePermission =
- impersonatorUserWorkspace.user.canImpersonate === true &&
- impersonatedUserWorkspace.workspace.allowImpersonation === true;
-
- if (isServerLevelImpersonation) {
- if (!hasServerLevelImpersonatePermission)
- throw new AuthException(
- 'Server level impersonation not allowed',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
-
- return {
- impersonatorUserWorkspaceId: payload.impersonatorUserWorkspaceId,
- impersonatedUserWorkspaceId: payload.impersonatedUserWorkspaceId,
- };
- }
-
- const hasWorkspaceLevelImpersonatePermission =
- await this.permissionsService.userHasWorkspaceSettingPermission({
- userWorkspaceId: impersonatorUserWorkspace.id,
- setting: PermissionFlagType.IMPERSONATE,
- workspaceId: impersonatedUserWorkspace.workspace.id,
- });
-
- if (!hasWorkspaceLevelImpersonatePermission) {
- throw new AuthException(
- 'Impersonation not allowed',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ const authorizationResult =
+ await this.impersonationAuthorizationService.checkImpersonationAuthorization(
+ impersonatorUserWorkspace,
+ impersonatedUserWorkspace,
);
- }
- const targetHasAdminPrivileges =
- impersonatedUserWorkspace.user.canImpersonate === true ||
- impersonatedUserWorkspace.user.canAccessFullAdminPanel === true;
+ if (!authorizationResult.allowed) {
+ const { message, exceptionCode, userFriendlyMessage } =
+ IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason];
- const impersonatorHasAdminPrivileges =
- impersonatorUserWorkspace.user.canImpersonate === true ||
- impersonatorUserWorkspace.user.canAccessFullAdminPanel === true;
-
- if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) {
- throw new AuthException(
- 'Cannot impersonate a user with admin privileges',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
+ throw new AuthException(message, exceptionCode, { userFriendlyMessage });
}
return {
diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
index 5221174eb6..8fd700d027 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts
@@ -11,6 +11,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
+import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -33,6 +34,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
PermissionsModule,
WorkspaceCacheModule,
CoreEntityCacheModule,
+ ImpersonationAuthorizationModule,
],
providers: [
RenewTokenService,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts
index 8a0b0cdf53..51438be101 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts
@@ -45,6 +45,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
throw new ForbiddenError(exception.message, {
subCode: exception.code,
+ userFriendlyMessage: exception.userFriendlyMessage,
});
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED:
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts
new file mode 100644
index 0000000000..58c6159997
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation-authorization.service.spec.ts
@@ -0,0 +1,411 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+
+import { PermissionFlagType } from 'twenty-shared/constants';
+
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
+import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants';
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+
+const userHasWorkspaceSettingPermissionMock = jest.fn();
+const twentyConfigServiceGetMock = jest.fn();
+
+const VERIFIED_TWO_FACTOR_METHODS = [
+ { status: OTPStatus.VERIFIED },
+] as unknown as UserWorkspaceEntity['twoFactorAuthenticationMethods'];
+
+type BuildUserWorkspaceParams = {
+ userWorkspaceId?: string;
+ userId?: string;
+ workspaceId?: string;
+ canImpersonate?: boolean;
+ canAccessFullAdminPanel?: boolean;
+ allowImpersonation?: boolean;
+ twoFactorAuthenticationMethods?: UserWorkspaceEntity['twoFactorAuthenticationMethods'];
+};
+
+const buildUserWorkspace = ({
+ userWorkspaceId = 'user-workspace-id',
+ userId = 'user-id',
+ workspaceId = 'workspace-id',
+ canImpersonate = false,
+ canAccessFullAdminPanel = false,
+ allowImpersonation = false,
+ twoFactorAuthenticationMethods = [],
+}: BuildUserWorkspaceParams): UserWorkspaceEntity =>
+ ({
+ id: userWorkspaceId,
+ userId,
+ user: {
+ id: userId,
+ canImpersonate,
+ canAccessFullAdminPanel,
+ },
+ workspace: {
+ id: workspaceId,
+ allowImpersonation,
+ },
+ twoFactorAuthenticationMethods,
+ }) as unknown as UserWorkspaceEntity;
+
+describe('ImpersonationAuthorizationService', () => {
+ let service: ImpersonationAuthorizationService;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ twentyConfigServiceGetMock.mockImplementation((key: string) =>
+ key === 'NODE_ENV' ? NodeEnvironment.PRODUCTION : undefined,
+ );
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ImpersonationAuthorizationService,
+ {
+ provide: PermissionsService,
+ useValue: {
+ userHasWorkspaceSettingPermission:
+ userHasWorkspaceSettingPermissionMock,
+ },
+ },
+ {
+ provide: TwentyConfigService,
+ useValue: {
+ get: twentyConfigServiceGetMock,
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(
+ ImpersonationAuthorizationService,
+ );
+ });
+
+ describe('getImpersonationLevel', () => {
+ it('should return workspace when impersonator and target share a workspace', () => {
+ const impersonator = buildUserWorkspace({ workspaceId: 'workspace-1' });
+ const target = buildUserWorkspace({
+ userId: 'other',
+ workspaceId: 'workspace-1',
+ });
+
+ expect(service.getImpersonationLevel(impersonator, target)).toBe(
+ 'workspace',
+ );
+ });
+
+ it('should return server when impersonator and target are in different workspaces', () => {
+ const impersonator = buildUserWorkspace({ workspaceId: 'workspace-1' });
+ const target = buildUserWorkspace({
+ userId: 'other',
+ workspaceId: 'workspace-2',
+ });
+
+ expect(service.getImpersonationLevel(impersonator, target)).toBe(
+ 'server',
+ );
+ });
+ });
+
+ describe('server-level impersonation', () => {
+ it('should allow when impersonator can impersonate, has verified 2FA and target workspace allows it', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ twoFactorAuthenticationMethods: VERIFIED_TWO_FACTOR_METHODS,
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({ allowed: true, level: 'server' });
+ expect(userHasWorkspaceSettingPermissionMock).not.toHaveBeenCalled();
+ });
+
+ it('should deny with provisioning reason when impersonator has no 2FA method (production)', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ twoFactorAuthenticationMethods: [],
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'server',
+ reason: 'SERVER_LEVEL_2FA_PROVISION_REQUIRED',
+ });
+ });
+
+ it('should deny with verification reason when impersonator has an unverified 2FA method (production)', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ twoFactorAuthenticationMethods: [
+ { status: OTPStatus.PENDING },
+ ] as unknown as UserWorkspaceEntity['twoFactorAuthenticationMethods'],
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'server',
+ reason: 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED',
+ });
+ });
+
+ it('should allow server-level impersonation without 2FA in development', async () => {
+ twentyConfigServiceGetMock.mockImplementation((key: string) =>
+ key === 'NODE_ENV' ? NodeEnvironment.DEVELOPMENT : undefined,
+ );
+
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ twoFactorAuthenticationMethods: [],
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({ allowed: true, level: 'server' });
+ });
+
+ it('should deny when impersonator cannot impersonate', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: false,
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'server',
+ reason: 'SERVER_LEVEL_NOT_ALLOWED',
+ });
+ });
+
+ it('should deny when target workspace does not allow impersonation', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: false,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'server',
+ reason: 'SERVER_LEVEL_NOT_ALLOWED',
+ });
+ });
+
+ it('should NOT apply the admin-privilege check at server level (a server-level impersonator may impersonate an admin)', async () => {
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ canAccessFullAdminPanel: false,
+ twoFactorAuthenticationMethods: VERIFIED_TWO_FACTOR_METHODS,
+ });
+ const target = buildUserWorkspace({
+ userId: 'admin-target',
+ workspaceId: 'workspace-2',
+ allowImpersonation: true,
+ canAccessFullAdminPanel: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({ allowed: true, level: 'server' });
+ });
+ });
+
+ describe('workspace-level impersonation', () => {
+ it('should allow when impersonator has the IMPERSONATE permission and target is not an admin', async () => {
+ userHasWorkspaceSettingPermissionMock.mockResolvedValue(true);
+
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-1',
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({ allowed: true, level: 'workspace' });
+ expect(userHasWorkspaceSettingPermissionMock).toHaveBeenCalledWith({
+ userWorkspaceId: impersonator.id,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId: target.workspace.id,
+ });
+ });
+
+ it('should deny when impersonator lacks the IMPERSONATE permission', async () => {
+ userHasWorkspaceSettingPermissionMock.mockResolvedValue(false);
+
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ });
+ const target = buildUserWorkspace({
+ userId: 'target',
+ workspaceId: 'workspace-1',
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'workspace',
+ reason: 'WORKSPACE_LEVEL_NOT_ALLOWED',
+ });
+ });
+
+ it('should deny when a non-admin tries to impersonate an admin (canAccessFullAdminPanel)', async () => {
+ userHasWorkspaceSettingPermissionMock.mockResolvedValue(true);
+
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: false,
+ canAccessFullAdminPanel: false,
+ });
+ const target = buildUserWorkspace({
+ userId: 'admin-target',
+ workspaceId: 'workspace-1',
+ canAccessFullAdminPanel: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'workspace',
+ reason: 'TARGET_HAS_ADMIN_PRIVILEGES',
+ });
+ });
+
+ it('should deny when a non-admin tries to impersonate an admin (canImpersonate)', async () => {
+ userHasWorkspaceSettingPermissionMock.mockResolvedValue(true);
+
+ const impersonator = buildUserWorkspace({
+ userId: 'impersonator',
+ workspaceId: 'workspace-1',
+ canImpersonate: false,
+ canAccessFullAdminPanel: false,
+ });
+ const target = buildUserWorkspace({
+ userId: 'admin-target',
+ workspaceId: 'workspace-1',
+ canImpersonate: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ level: 'workspace',
+ reason: 'TARGET_HAS_ADMIN_PRIVILEGES',
+ });
+ });
+
+ it('should allow an admin to impersonate another admin', async () => {
+ userHasWorkspaceSettingPermissionMock.mockResolvedValue(true);
+
+ const impersonator = buildUserWorkspace({
+ userId: 'admin-impersonator',
+ workspaceId: 'workspace-1',
+ canAccessFullAdminPanel: true,
+ });
+ const target = buildUserWorkspace({
+ userId: 'admin-target',
+ workspaceId: 'workspace-1',
+ canAccessFullAdminPanel: true,
+ });
+
+ const result = await service.checkImpersonationAuthorization(
+ impersonator,
+ target,
+ );
+
+ expect(result).toEqual({ allowed: true, level: 'workspace' });
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts
index 5eecad9566..741ceff15c 100644
--- a/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/__tests__/impersonation.service.spec.ts
@@ -3,13 +3,14 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
-import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
+import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants';
@@ -36,6 +37,7 @@ describe('ImpersonationService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ImpersonationService,
+ ImpersonationAuthorizationService,
{
provide: getRepositoryToken(UserEntity),
useValue: {
@@ -366,7 +368,7 @@ describe('ImpersonationService', () => {
),
).rejects.toThrow(
new AuthException(
- 'Impersonation not enabled for the impersonator user or the target workspace',
+ 'Server level impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
@@ -409,7 +411,7 @@ describe('ImpersonationService', () => {
),
).rejects.toThrow(
new AuthException(
- 'Impersonation not enabled for the impersonator user or the target workspace',
+ 'Server level impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
@@ -698,7 +700,7 @@ describe('ImpersonationService', () => {
).rejects.toThrow(
new AuthException(
'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED,
),
);
});
@@ -752,8 +754,8 @@ describe('ImpersonationService', () => {
),
).rejects.toThrow(
new AuthException(
- 'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ 'Two-factor authentication is required for server-level impersonation. Please verify your 2FA method before attempting to impersonate users.',
+ AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED,
),
);
});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts
new file mode 100644
index 0000000000..6ecb0c7566
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant.ts
@@ -0,0 +1,47 @@
+import { type MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+
+import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
+import { type ImpersonationDenialReason } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
+
+type ImpersonationDenial = {
+ message: string;
+ exceptionCode: (typeof AuthExceptionCode)[keyof typeof AuthExceptionCode];
+ userFriendlyMessage: MessageDescriptor;
+};
+
+export const IMPERSONATION_DENIAL_BY_REASON: Record<
+ ImpersonationDenialReason,
+ ImpersonationDenial
+> = {
+ SERVER_LEVEL_NOT_ALLOWED: {
+ message: 'Server level impersonation not allowed',
+ exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ userFriendlyMessage: msg`Server-level impersonation is not enabled for this user or workspace.`,
+ },
+ SERVER_LEVEL_2FA_PROVISION_REQUIRED: {
+ message:
+ 'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
+ exceptionCode:
+ AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED,
+ userFriendlyMessage: msg`Set up two-factor authentication before impersonating users in another workspace.`,
+ },
+ SERVER_LEVEL_2FA_VERIFICATION_REQUIRED: {
+ message:
+ 'Two-factor authentication is required for server-level impersonation. Please verify your 2FA method before attempting to impersonate users.',
+ exceptionCode:
+ AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED,
+ userFriendlyMessage: msg`Verify your two-factor authentication before impersonating users in another workspace.`,
+ },
+ WORKSPACE_LEVEL_NOT_ALLOWED: {
+ message: 'Impersonation not allowed',
+ exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ userFriendlyMessage: msg`You do not have permission to impersonate users in this workspace.`,
+ },
+ TARGET_HAS_ADMIN_PRIVILEGES: {
+ message:
+ 'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.',
+ exceptionCode: AuthExceptionCode.FORBIDDEN_EXCEPTION,
+ userFriendlyMessage: msg`Only administrators can impersonate users with admin privileges.`,
+ },
+};
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts
new file mode 100644
index 0000000000..68454dddb1
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/constants/impersonation-denial-log-message-by-reason.constant.ts
@@ -0,0 +1,23 @@
+import { type ImpersonationDenialReason } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
+
+export const IMPERSONATION_DENIAL_LOG_MESSAGE_BY_REASON: Record<
+ ImpersonationDenialReason,
+ (params: { targetUserEmail: string; impersonatorUserId: string }) => string
+> = {
+ SERVER_LEVEL_NOT_ALLOWED: ({ targetUserEmail, impersonatorUserId }) =>
+ `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserId}`,
+ SERVER_LEVEL_2FA_PROVISION_REQUIRED: ({
+ targetUserEmail,
+ impersonatorUserId,
+ }) =>
+ `Server level impersonation denied (2FA provisioning required) for ${targetUserEmail} by userId ${impersonatorUserId}`,
+ SERVER_LEVEL_2FA_VERIFICATION_REQUIRED: ({
+ targetUserEmail,
+ impersonatorUserId,
+ }) =>
+ `Server level impersonation denied (2FA verification required) for ${targetUserEmail} by userId ${impersonatorUserId}`,
+ WORKSPACE_LEVEL_NOT_ALLOWED: ({ targetUserEmail, impersonatorUserId }) =>
+ `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserId}`,
+ TARGET_HAS_ADMIN_PRIVILEGES: ({ targetUserEmail, impersonatorUserId }) =>
+ `Impersonation of admin user ${targetUserEmail} denied for non-admin userId ${impersonatorUserId}`,
+};
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts
new file mode 100644
index 0000000000..b3c88df595
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation-authorization.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
+
+@Module({
+ imports: [PermissionsModule],
+ providers: [ImpersonationAuthorizationService],
+ exports: [ImpersonationAuthorizationService],
+})
+export class ImpersonationAuthorizationModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts
index f093e44c69..48bb1b4ab1 100644
--- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.module.ts
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
-import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
+import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
+import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module';
import { ImpersonationResolver } from 'src/engine/core-modules/impersonation/impersonation.resolver';
import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -17,6 +18,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.
@Module({
imports: [
AuthModule,
+ ImpersonationAuthorizationModule,
UserWorkspaceModule,
PermissionsModule,
RoleModule,
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts
index 2e55b373b7..0613144e8f 100644
--- a/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/impersonation.resolver.ts
@@ -1,4 +1,4 @@
-import { UseGuards, UsePipes } from '@nestjs/common';
+import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
@@ -7,8 +7,11 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
+import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
+import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { ImpersonationService } from 'src/engine/core-modules/impersonation/services/impersonation.service';
+import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { ImpersonatePermissionGuard } from 'src/engine/guards/impersonate-permission.guard';
@@ -19,6 +22,11 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
+@UseFilters(
+ AuthGraphqlApiExceptionFilter,
+ PermissionsGraphqlApiExceptionFilter,
+ PreventNestToAutoLogGraphqlErrorsFilter,
+)
export class ImpersonationResolver {
constructor(private readonly impersonationService: ImpersonationService) {}
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts
new file mode 100644
index 0000000000..0547d6bd63
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation-authorization.service.ts
@@ -0,0 +1,129 @@
+import { Injectable } from '@nestjs/common';
+
+import { PermissionFlagType } from 'twenty-shared/constants';
+import { isDefined } from 'twenty-shared/utils';
+
+import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util';
+import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { twoFactorAuthenticationMethodsValidator } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation';
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+
+export type ImpersonationLevel = 'server' | 'workspace';
+
+export type ImpersonationDenialReason =
+ | 'SERVER_LEVEL_NOT_ALLOWED'
+ | 'SERVER_LEVEL_2FA_PROVISION_REQUIRED'
+ | 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED'
+ | 'WORKSPACE_LEVEL_NOT_ALLOWED'
+ | 'TARGET_HAS_ADMIN_PRIVILEGES';
+
+export type ImpersonationAuthorizationResult =
+ | { allowed: true; level: ImpersonationLevel }
+ | {
+ allowed: false;
+ level: ImpersonationLevel;
+ reason: ImpersonationDenialReason;
+ };
+
+@Injectable()
+export class ImpersonationAuthorizationService {
+ constructor(
+ private readonly permissionsService: PermissionsService,
+ private readonly twentyConfigService: TwentyConfigService,
+ ) {}
+
+ getImpersonationLevel(
+ impersonatorUserWorkspace: UserWorkspaceEntity,
+ targetUserWorkspace: UserWorkspaceEntity,
+ ): ImpersonationLevel {
+ return targetUserWorkspace.workspace.id !==
+ impersonatorUserWorkspace.workspace.id
+ ? 'server'
+ : 'workspace';
+ }
+
+ async checkImpersonationAuthorization(
+ impersonatorUserWorkspace: UserWorkspaceEntity,
+ targetUserWorkspace: UserWorkspaceEntity,
+ ): Promise {
+ const level = this.getImpersonationLevel(
+ impersonatorUserWorkspace,
+ targetUserWorkspace,
+ );
+
+ if (level === 'server') {
+ const hasServerLevelImpersonatePermission =
+ impersonatorUserWorkspace.user.canImpersonate === true &&
+ targetUserWorkspace.workspace.allowImpersonation === true;
+
+ if (!hasServerLevelImpersonatePermission) {
+ return { allowed: false, level, reason: 'SERVER_LEVEL_NOT_ALLOWED' };
+ }
+
+ if (this.isTwoFactorRequiredForServerLevelImpersonation()) {
+ const twoFactorDenialReason = this.getServerLevelTwoFactorDenialReason(
+ impersonatorUserWorkspace,
+ );
+
+ if (isDefined(twoFactorDenialReason)) {
+ return { allowed: false, level, reason: twoFactorDenialReason };
+ }
+ }
+
+ return { allowed: true, level };
+ }
+
+ const hasWorkspaceLevelImpersonatePermission =
+ await this.permissionsService.userHasWorkspaceSettingPermission({
+ userWorkspaceId: impersonatorUserWorkspace.id,
+ setting: PermissionFlagType.IMPERSONATE,
+ workspaceId: targetUserWorkspace.workspace.id,
+ });
+
+ if (!hasWorkspaceLevelImpersonatePermission) {
+ return { allowed: false, level, reason: 'WORKSPACE_LEVEL_NOT_ALLOWED' };
+ }
+
+ if (
+ userHasAdminPrivileges(targetUserWorkspace.user) &&
+ !userHasAdminPrivileges(impersonatorUserWorkspace.user)
+ ) {
+ return { allowed: false, level, reason: 'TARGET_HAS_ADMIN_PRIVILEGES' };
+ }
+
+ return { allowed: true, level };
+ }
+
+ private isTwoFactorRequiredForServerLevelImpersonation(): boolean {
+ return (
+ this.twentyConfigService.get('NODE_ENV') !== NodeEnvironment.DEVELOPMENT
+ );
+ }
+
+ private getServerLevelTwoFactorDenialReason(
+ impersonatorUserWorkspace: UserWorkspaceEntity,
+ ): ImpersonationDenialReason | undefined {
+ const twoFactorAuthenticationMethods =
+ impersonatorUserWorkspace.twoFactorAuthenticationMethods;
+
+ if (
+ !twoFactorAuthenticationMethodsValidator.areDefined(
+ twoFactorAuthenticationMethods,
+ )
+ ) {
+ return 'SERVER_LEVEL_2FA_PROVISION_REQUIRED';
+ }
+
+ if (
+ !twoFactorAuthenticationMethodsValidator.areVerified(
+ twoFactorAuthenticationMethods,
+ )
+ ) {
+ return 'SERVER_LEVEL_2FA_VERIFICATION_REQUIRED';
+ }
+
+ return undefined;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts
index bcf3a1d492..cb593af885 100644
--- a/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/services/impersonation.service.ts
@@ -1,25 +1,21 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
-import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
-
-import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
-import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
-import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
-import { twoFactorAuthenticationMethodsValidator } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation';
+import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
+import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
+import { IMPERSONATION_DENIAL_BY_REASON } from 'src/engine/core-modules/impersonation/constants/impersonation-denial-by-reason.constant';
+import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
-import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class ImpersonationService {
@@ -27,10 +23,9 @@ export class ImpersonationService {
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly loginTokenService: LoginTokenService,
- private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository,
- private readonly permissionsService: PermissionsService,
+ private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
) {}
async impersonate(
@@ -72,89 +67,23 @@ export class ImpersonationService {
);
}
- const isServerLevelImpersonation =
- toImpersonateUserWorkspace.workspace.id !==
- impersonatorUserWorkspace.workspace.id;
-
- const hasServerLevelImpersonatePermission =
- impersonatorUserWorkspace.user.canImpersonate === true &&
- toImpersonateUserWorkspace.workspace.allowImpersonation === true;
-
- if (isServerLevelImpersonation) {
- if (!hasServerLevelImpersonatePermission) {
- throw new AuthException(
- 'Impersonation not enabled for the impersonator user or the target workspace',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
- }
-
- const isDevelopment =
- this.twentyConfigService.get('NODE_ENV') ===
- NodeEnvironment.DEVELOPMENT;
-
- if (isDevelopment) {
- return this.generateImpersonationLoginToken(
- impersonatorUserWorkspace,
- toImpersonateUserWorkspace,
- 'server',
- );
- }
-
- const has2FAEnabled =
- twoFactorAuthenticationMethodsValidator.areDefined(
- impersonatorUserWorkspace.twoFactorAuthenticationMethods,
- ) &&
- twoFactorAuthenticationMethodsValidator.areVerified(
- impersonatorUserWorkspace.twoFactorAuthenticationMethods,
- );
-
- if (!has2FAEnabled) {
- throw new AuthException(
- 'Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.',
- AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED,
- );
- }
-
- return this.generateImpersonationLoginToken(
+ const authorizationResult =
+ await this.impersonationAuthorizationService.checkImpersonationAuthorization(
impersonatorUserWorkspace,
toImpersonateUserWorkspace,
- 'server',
);
- }
- const hasWorkspaceLevelImpersonatePermission =
- await this.permissionsService.userHasWorkspaceSettingPermission({
- userWorkspaceId: impersonatorUserWorkspace.id,
- setting: PermissionFlagType.IMPERSONATE,
- workspaceId: workspaceId,
- });
+ if (!authorizationResult.allowed) {
+ const { message, exceptionCode, userFriendlyMessage } =
+ IMPERSONATION_DENIAL_BY_REASON[authorizationResult.reason];
- if (!hasWorkspaceLevelImpersonatePermission) {
- throw new AuthException(
- 'Impersonation not enabled for this workspace',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
- }
-
- const targetHasAdminPrivileges =
- toImpersonateUserWorkspace.user.canImpersonate === true ||
- toImpersonateUserWorkspace.user.canAccessFullAdminPanel === true;
-
- const impersonatorHasAdminPrivileges =
- impersonatorUserWorkspace.user.canImpersonate === true ||
- impersonatorUserWorkspace.user.canAccessFullAdminPanel === true;
-
- if (targetHasAdminPrivileges && !impersonatorHasAdminPrivileges) {
- throw new AuthException(
- 'Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.',
- AuthExceptionCode.FORBIDDEN_EXCEPTION,
- );
+ throw new AuthException(message, exceptionCode, { userFriendlyMessage });
}
return this.generateImpersonationLoginToken(
impersonatorUserWorkspace,
toImpersonateUserWorkspace,
- 'workspace',
+ authorizationResult.level,
);
}
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts
new file mode 100644
index 0000000000..b0d0de5a2d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-can-server-impersonate.util.spec.ts
@@ -0,0 +1,18 @@
+import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util';
+
+describe('userCanServerImpersonate', () => {
+ it('should be true only when canImpersonate is true', () => {
+ expect(
+ userCanServerImpersonate({
+ canImpersonate: true,
+ canAccessFullAdminPanel: false,
+ }),
+ ).toBe(true);
+ expect(
+ userCanServerImpersonate({
+ canImpersonate: false,
+ canAccessFullAdminPanel: true,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts
new file mode 100644
index 0000000000..1b3323013c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-has-admin-privileges.util.spec.ts
@@ -0,0 +1,30 @@
+import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util';
+
+describe('userHasAdminPrivileges', () => {
+ it('should be true when canImpersonate is true', () => {
+ expect(
+ userHasAdminPrivileges({
+ canImpersonate: true,
+ canAccessFullAdminPanel: false,
+ }),
+ ).toBe(true);
+ });
+
+ it('should be true when canAccessFullAdminPanel is true', () => {
+ expect(
+ userHasAdminPrivileges({
+ canImpersonate: false,
+ canAccessFullAdminPanel: true,
+ }),
+ ).toBe(true);
+ });
+
+ it('should be false when neither privilege is set', () => {
+ expect(
+ userHasAdminPrivileges({
+ canImpersonate: false,
+ canAccessFullAdminPanel: false,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts
new file mode 100644
index 0000000000..5105219c39
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/__tests__/user-is-full-admin.util.spec.ts
@@ -0,0 +1,18 @@
+import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util';
+
+describe('userIsFullAdmin', () => {
+ it('should be true only when canAccessFullAdminPanel is true', () => {
+ expect(
+ userIsFullAdmin({
+ canAccessFullAdminPanel: true,
+ canImpersonate: false,
+ }),
+ ).toBe(true);
+ expect(
+ userIsFullAdmin({
+ canAccessFullAdminPanel: false,
+ canImpersonate: true,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts
new file mode 100644
index 0000000000..a0a26e0ecd
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type.ts
@@ -0,0 +1,4 @@
+export type ImpersonationAuthorizationUser = {
+ canImpersonate: boolean;
+ canAccessFullAdminPanel: boolean;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts
new file mode 100644
index 0000000000..b3fb1c934d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/index.ts
@@ -0,0 +1,4 @@
+export { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type';
+export { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util';
+export { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util';
+export { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util';
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts
new file mode 100644
index 0000000000..6905e035a0
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util.ts
@@ -0,0 +1,5 @@
+import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type';
+
+export const userCanServerImpersonate = (
+ user: ImpersonationAuthorizationUser,
+): boolean => user.canImpersonate === true;
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts
new file mode 100644
index 0000000000..3f2051c136
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util.ts
@@ -0,0 +1,7 @@
+import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type';
+import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util';
+import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util';
+
+export const userHasAdminPrivileges = (
+ user: ImpersonationAuthorizationUser,
+): boolean => userCanServerImpersonate(user) || userIsFullAdmin(user);
diff --git a/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts
new file mode 100644
index 0000000000..076e79c335
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/impersonation/utils/user-is-full-admin.util.ts
@@ -0,0 +1,5 @@
+import { type ImpersonationAuthorizationUser } from 'src/engine/core-modules/impersonation/utils/impersonation-authorization-user.type';
+
+export const userIsFullAdmin = (
+ user: ImpersonationAuthorizationUser,
+): boolean => user.canAccessFullAdminPanel === true;
diff --git a/packages/twenty-server/src/engine/guards/admin-panel-guard.ts b/packages/twenty-server/src/engine/guards/admin-panel-guard.ts
index 52a951a8b6..542cd6d526 100644
--- a/packages/twenty-server/src/engine/guards/admin-panel-guard.ts
+++ b/packages/twenty-server/src/engine/guards/admin-panel-guard.ts
@@ -3,6 +3,8 @@ import { GqlExecutionContext } from '@nestjs/graphql';
import { type Observable } from 'rxjs';
+import { userIsFullAdmin } from 'src/engine/core-modules/impersonation/utils/user-is-full-admin.util';
+
export class AdminPanelGuard implements CanActivate {
canActivate(
context: ExecutionContext,
@@ -10,6 +12,6 @@ export class AdminPanelGuard implements CanActivate {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
- return request.user.canAccessFullAdminPanel === true;
+ return userIsFullAdmin(request.user);
}
}
diff --git a/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts
index 141d859c5e..d1663d6385 100644
--- a/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts
+++ b/packages/twenty-server/src/engine/guards/admin-panel-or-impersonate.guard.ts
@@ -1,6 +1,8 @@
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
+import { userHasAdminPrivileges } from 'src/engine/core-modules/impersonation/utils/user-has-admin-privileges.util';
+
// Read-only admin-panel lookups (user/recent-users search) are available to
// full admins as well as impersonators: managing server-admin access requires
// finding users, and a full admin is the higher privilege.
@@ -9,9 +11,6 @@ export class AdminPanelOrImpersonateGuard implements CanActivate {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
- return (
- request.user.canAccessFullAdminPanel === true ||
- request.user.canImpersonate === true
- );
+ return userHasAdminPrivileges(request.user);
}
}
diff --git a/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts
index 9994043776..171af3ba6a 100644
--- a/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts
+++ b/packages/twenty-server/src/engine/guards/impersonate-permission.guard.ts
@@ -9,6 +9,7 @@ import { msg } from '@lingui/core/macro';
import { isDefined } from 'class-validator';
import { PermissionFlagType } from 'twenty-shared/constants';
+import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util';
import {
PermissionsException,
PermissionsExceptionCode,
@@ -36,7 +37,9 @@ export class ImpersonatePermissionGuard implements CanActivate {
);
}
- if (request.user.canImpersonate === true) return true;
+ if (userCanServerImpersonate(request.user)) {
+ return true;
+ }
const hasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
diff --git a/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts b/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts
index 872a035b55..80301dc875 100644
--- a/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts
+++ b/packages/twenty-server/src/engine/guards/server-level-impersonate.guard.ts
@@ -1,11 +1,13 @@
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
+import { userCanServerImpersonate } from 'src/engine/core-modules/impersonation/utils/user-can-server-impersonate.util';
+
export class ServerLevelImpersonateGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
- return request.user.canImpersonate === true;
+ return userCanServerImpersonate(request.user);
}
}
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts
index 306e747e8b..43b8b8b909 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts
@@ -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,
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 {
+ 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,
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts
new file mode 100644
index 0000000000..ec805c3d92
--- /dev/null
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-two-factor-authentication-methods.util.ts
@@ -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();
+};
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts
index c786a390e9..1784d34a4e 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts
@@ -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];
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts
index cc902b234d..2ca53d47ad 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts
@@ -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];
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts
index 8c56651c4b..8fadf15cb9 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts
index 66666bb0bd..671424d4bf 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts
@@ -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],
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts
index 56409f35a5..24999287e2 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts
@@ -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(
{
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap
new file mode 100644
index 0000000000..0b1e4f792b
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-impersonation-access-token.integration-spec.ts.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Impersonation - access token validation denial (integration) rejects an invalid impersonation access token on a protected query 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "FORBIDDEN_EXCEPTION",
+ "userFriendlyMessage": {
+ "id": Any,
+ "message": "You do not have permission to impersonate users in this workspace.",
+ },
+ },
+ "message": "Impersonation not allowed",
+}
+`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap
new file mode 100644
index 0000000000..79451de49e
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-self-impersonation.integration-spec.ts.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Impersonation - self-impersonation denial (integration) rejects a user impersonating themselves 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "FORBIDDEN_EXCEPTION",
+ "userFriendlyMessage": "You do not have permission to perform this action.",
+ },
+ "message": "User cannot impersonate themselves",
+ "name": "ForbiddenError",
+}
+`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap
new file mode 100644
index 0000000000..7e8fff138e
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/__snapshots__/failing-unauthenticated-impersonation.integration-spec.ts.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Impersonation - unauthenticated request denial (integration) rejects an impersonation request with no authentication 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "userFriendlyMessage": "An error occurred.",
+ },
+ "message": "Forbidden resource",
+}
+`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts
new file mode 100644
index 0000000000..ebb8fad2bc
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-impersonation-access-token.integration-spec.ts
@@ -0,0 +1,29 @@
+import gql from 'graphql-tag';
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
+
+// Exercises the per-request JWT validation checkpoint (jwt.auth.strategy):
+// an impersonation access token whose impersonator is not authorized to
+// impersonate the target must be rejected on every protected query.
+describe('Impersonation - access token validation denial (integration)', () => {
+ it('rejects an invalid impersonation access token on a protected query', async () => {
+ const query = gql`
+ query People {
+ people {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ `;
+
+ const response = await makeGraphqlAPIRequest(
+ { query },
+ APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN,
+ );
+
+ expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..44272897b2
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-self-impersonation.integration-spec.ts
@@ -0,0 +1,18 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
+
+import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+describe('Impersonation - self-impersonation denial (integration)', () => {
+ it('rejects a user impersonating themselves', async () => {
+ const { errors } = await impersonate({
+ userId: USER_DATA_SEED_IDS.JANE,
+ workspaceId: SEED_APPLE_WORKSPACE_ID,
+ accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..2faaae18a7
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/failing-unauthenticated-impersonation.integration-spec.ts
@@ -0,0 +1,32 @@
+import request from 'supertest';
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+
+import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+const SERVER_URL = `http://localhost:${APP_PORT}`;
+
+describe('Impersonation - unauthenticated request denial (integration)', () => {
+ it('rejects an impersonation request with no authentication', async () => {
+ const response = await request(SERVER_URL)
+ .post('/metadata')
+ .send({
+ query: `
+ mutation Impersonate($userId: UUID!, $workspaceId: UUID!) {
+ impersonate(userId: $userId, workspaceId: $workspaceId) {
+ loginToken {
+ token
+ }
+ }
+ }
+ `,
+ variables: {
+ userId: USER_DATA_SEED_IDS.JONY,
+ workspaceId: SEED_APPLE_WORKSPACE_ID,
+ },
+ })
+ .expect(200);
+
+ expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap
new file mode 100644
index 0000000000..abbcb2641a
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/__snapshots__/failing-server-impersonation.integration-spec.ts.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Server-level impersonation - authorization denials (integration) rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "FORBIDDEN_EXCEPTION",
+ "userFriendlyMessage": "Server-level impersonation is not enabled for this user or workspace.",
+ },
+ "message": "Server level impersonation not allowed",
+ "name": "ForbiddenError",
+}
+`;
+
+exports[`Server-level impersonation - authorization denials (integration) rejects cross-workspace impersonation when the impersonator has no verified 2FA 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED",
+ "userFriendlyMessage": "Set up two-factor authentication before impersonating users in another workspace.",
+ },
+ "message": "Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.",
+ "name": "ForbiddenError",
+}
+`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..942c13c7cf
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/failing-server-impersonation.integration-spec.ts
@@ -0,0 +1,40 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util';
+import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
+
+import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+// Jony has the server-level canImpersonate capability but no verified 2FA
+// method. Outside development, server-level (cross-workspace) impersonation
+// requires verified 2FA, so the attempt to impersonate Tim in the YCombinator
+// workspace must be denied at the token-generation checkpoint.
+const IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN = APPLE_JONY_MEMBER_ACCESS_TOKEN;
+
+describe('Server-level impersonation - authorization denials (integration)', () => {
+ it('rejects cross-workspace impersonation when the impersonator has no verified 2FA', async () => {
+ const { errors } = await impersonate({
+ userId: USER_DATA_SEED_IDS.TIM,
+ workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
+ accessToken: IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN,
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ it('rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces', async () => {
+ const scottAccessToken = await getAccessTokenForCredentials({
+ email: 'scott.forstall@apple.dev',
+ });
+
+ const { errors } = await impersonate({
+ userId: USER_DATA_SEED_IDS.JONY,
+ workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
+ accessToken: scottAccessToken,
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..cf9e46f3d1
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/server-level/successful-server-impersonation.integration-spec.ts
@@ -0,0 +1,58 @@
+import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
+import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
+import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
+
+import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+// Jane (authenticated in Apple) can impersonate across workspaces because she
+// has the server-level canImpersonate capability and a seeded verified 2FA
+// method. The target, Tim, is impersonated in the YCombinator workspace.
+const SERVER_IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN;
+
+describe('Server-level impersonation - successful flow (integration)', () => {
+ it('lets a server admin with verified 2FA impersonate a user in another workspace', async () => {
+ const { data: impersonateData, errors: impersonateErrors } =
+ await impersonate({
+ userId: USER_DATA_SEED_IDS.TIM,
+ workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
+ accessToken: SERVER_IMPERSONATOR_ACCESS_TOKEN,
+ expectToFail: false,
+ });
+
+ expect(impersonateErrors).toBeUndefined();
+
+ const loginToken = impersonateData.impersonate.loginToken.token;
+ const origin = impersonateData.impersonate.workspace.workspaceUrls
+ .subdomainUrl as string;
+
+ expect(loginToken).toBeDefined();
+ expect(impersonateData.impersonate.workspace.id).toBe(
+ SEED_YCOMBINATOR_WORKSPACE_ID,
+ );
+
+ const { data: tokensData, errors: tokensErrors } =
+ await getAuthTokensFromLoginToken({
+ loginToken,
+ origin,
+ expectToFail: false,
+ });
+
+ expect(tokensErrors).toBeUndefined();
+
+ const impersonationAccessToken =
+ tokensData.getAuthTokensFromLoginToken.tokens
+ .accessOrWorkspaceAgnosticToken.token;
+
+ expect(impersonationAccessToken).toBeDefined();
+
+ const { data: currentUserData, errors: currentUserErrors } =
+ await getCurrentUser({
+ accessToken: impersonationAccessToken,
+ expectToFail: false,
+ });
+
+ expect(currentUserErrors).toBeUndefined();
+ expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.TIM);
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap
new file mode 100644
index 0000000000..46bfeadae1
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/__snapshots__/failing-workspace-impersonation.integration-spec.ts.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Workspace-level impersonation - authorization denials (integration) rejects a non-admin with the impersonate permission impersonating an admin 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "FORBIDDEN_EXCEPTION",
+ "userFriendlyMessage": "Only administrators can impersonate users with admin privileges.",
+ },
+ "message": "Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.",
+ "name": "ForbiddenError",
+}
+`;
+
+exports[`Workspace-level impersonation - authorization denials (integration) rejects a user without the impersonate permission 1`] = `
+{
+ "extensions": {
+ "code": "FORBIDDEN",
+ "subCode": "PERMISSION_DENIED",
+ "userFriendlyMessage": "User does not have permission.",
+ },
+ "message": "Entity performing the request does not have permission",
+ "name": "ForbiddenError",
+}
+`;
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..a515443368
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/failing-workspace-impersonation.integration-spec.ts
@@ -0,0 +1,44 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util';
+import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
+import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
+
+import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+describe('Workspace-level impersonation - authorization denials (integration)', () => {
+ it('rejects a user without the impersonate permission', async () => {
+ const memberWithoutPermissionAccessToken =
+ await signUpInWorkspaceAndGetAccessToken(
+ 'impersonation-no-permission@apple.dev',
+ );
+
+ const { errors } = await impersonate({
+ userId: USER_DATA_SEED_IDS.JONY,
+ workspaceId: SEED_APPLE_WORKSPACE_ID,
+ accessToken: memberWithoutPermissionAccessToken,
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+
+ // Scott is a seeded non-admin Apple member (canImpersonate /
+ // canAccessFullAdminPanel both false) whose role grants only the IMPERSONATE
+ // permission flag. He passes the impersonate permission guard but must still
+ // be blocked from impersonating an admin (Jony) by the escalation check.
+ it('rejects a non-admin with the impersonate permission impersonating an admin', async () => {
+ const scottAccessToken = await getAccessTokenForCredentials({
+ email: 'scott.forstall@apple.dev',
+ });
+
+ const { errors } = await impersonate({
+ userId: USER_DATA_SEED_IDS.JONY,
+ workspaceId: SEED_APPLE_WORKSPACE_ID,
+ accessToken: scottAccessToken,
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts
new file mode 100644
index 0000000000..e4346d8c35
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/impersonation/workspace-level/successful-workspace-impersonation.integration-spec.ts
@@ -0,0 +1,57 @@
+import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
+import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
+import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
+
+import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
+import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
+
+// Jane holds the Apple admin role (so she has the IMPERSONATE workspace
+// permission). Scott is a non-admin Apple member (canImpersonate /
+// canAccessFullAdminPanel both false), so this exercises the member-only
+// workspace impersonation path: an admin impersonating a regular member, with
+// the admin-target escalation guard correctly not triggering.
+const IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN;
+
+describe('Workspace-level impersonation - successful flow (integration)', () => {
+ it('lets a workspace admin impersonate a member and obtain a usable access token', async () => {
+ const { data: impersonateData, errors: impersonateErrors } =
+ await impersonate({
+ userId: USER_DATA_SEED_IDS.SCOTT,
+ workspaceId: SEED_APPLE_WORKSPACE_ID,
+ accessToken: IMPERSONATOR_ACCESS_TOKEN,
+ expectToFail: false,
+ });
+
+ expect(impersonateErrors).toBeUndefined();
+
+ const loginToken = impersonateData.impersonate.loginToken.token;
+ const origin = impersonateData.impersonate.workspace.workspaceUrls
+ .subdomainUrl as string;
+
+ expect(loginToken).toBeDefined();
+
+ const { data: tokensData, errors: tokensErrors } =
+ await getAuthTokensFromLoginToken({
+ loginToken,
+ origin,
+ expectToFail: false,
+ });
+
+ expect(tokensErrors).toBeUndefined();
+
+ const impersonationAccessToken =
+ tokensData.getAuthTokensFromLoginToken.tokens
+ .accessOrWorkspaceAgnosticToken.token;
+
+ expect(impersonationAccessToken).toBeDefined();
+
+ const { data: currentUserData, errors: currentUserErrors } =
+ await getCurrentUser({
+ accessToken: impersonationAccessToken,
+ expectToFail: false,
+ });
+
+ expect(currentUserErrors).toBeUndefined();
+ expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.SCOTT);
+ });
+});
diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts
deleted file mode 100644
index b8f5dbd197..0000000000
--- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/failing-impersonation.integration-spec.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import request from 'supertest';
-
-const client = request(`http://localhost:${APP_PORT}`);
-
-describe('peopleResolver (e2e)', () => {
- it('should find many people', () => {
- const queryData = {
- query: `
- query people {
- people {
- edges {
- node {
- jobTitle
- avatarUrl
- position
- searchVector
- id
- createdAt
- updatedAt
- deletedAt
- companyId
- intro
- workPreference
- performanceRating
- }
- }
- }
- }
- `,
- };
-
- return client
- .post('/graphql')
- .set(
- 'Authorization',
- `Bearer ${APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN}`,
- )
- .send(queryData)
- .expect(200)
- .expect((res) => {
- expect(res.body.errors).toBeDefined();
- expect(res.body.errors[0].message).toContain(
- 'Impersonation not allowed',
- );
- });
- });
-});
diff --git a/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts
new file mode 100644
index 0000000000..7826a1356c
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts
@@ -0,0 +1,67 @@
+import request from 'supertest';
+import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
+
+const SERVER_URL = `http://localhost:${APP_PORT}`;
+
+const buildAppleOrigin = (): string => {
+ const origin = new URL(SERVER_URL);
+
+ origin.hostname =
+ process.env.IS_MULTIWORKSPACE_ENABLED === 'true'
+ ? `apple.${origin.hostname}`
+ : origin.hostname;
+
+ return origin.toString();
+};
+
+type GetAccessTokenForCredentialsArgs = {
+ email: string;
+ password?: string;
+};
+
+// Logs a seeded Apple user in with email/password and returns a usable
+// access token, minting it dynamically rather than relying on a pre-baked
+// entry in test-tokens.json.
+export const getAccessTokenForCredentials = async ({
+ email,
+ password = 'tim@apple.dev',
+}: GetAccessTokenForCredentialsArgs): Promise => {
+ const origin = buildAppleOrigin();
+
+ const loginResponse = await request(SERVER_URL)
+ .post('/metadata')
+ .set('Origin', origin)
+ .send({
+ query: `
+ mutation GetLoginTokenFromCredentials(
+ $email: String!
+ $password: String!
+ $origin: String!
+ ) {
+ getLoginTokenFromCredentials(
+ email: $email
+ password: $password
+ origin: $origin
+ ) {
+ loginToken {
+ token
+ }
+ }
+ }
+ `,
+ variables: { email, password, origin },
+ })
+ .expect(200);
+
+ const loginToken =
+ loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token;
+
+ const { data } = await getAuthTokensFromLoginToken({
+ loginToken,
+ origin,
+ expectToFail: false,
+ });
+
+ return data.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
+ .token;
+};
diff --git a/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts b/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts
new file mode 100644
index 0000000000..2f2e255764
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/utils/impersonate.util.ts
@@ -0,0 +1,65 @@
+import gql from 'graphql-tag';
+import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
+import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
+import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
+import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
+
+import { type ImpersonateDTO } from 'src/engine/core-modules/admin-panel/dtos/impersonate.dto';
+
+type ImpersonateUtilArgs = {
+ userId: string;
+ workspaceId: string;
+ accessToken: string;
+ expectToFail?: boolean;
+};
+
+export const impersonate = async ({
+ userId,
+ workspaceId,
+ accessToken,
+ expectToFail,
+}: ImpersonateUtilArgs): CommonResponseBody<{
+ impersonate: ImpersonateDTO;
+}> => {
+ const mutation = gql`
+ mutation Impersonate($userId: UUID!, $workspaceId: UUID!) {
+ impersonate(userId: $userId, workspaceId: $workspaceId) {
+ loginToken {
+ token
+ expiresAt
+ }
+ workspace {
+ id
+ workspaceUrls {
+ subdomainUrl
+ customUrl
+ }
+ }
+ }
+ }
+ `;
+
+ const response = await makeMetadataAPIRequest(
+ {
+ query: mutation,
+ variables: { userId, workspaceId },
+ },
+ accessToken,
+ );
+
+ if (expectToFail === true) {
+ warnIfNoErrorButExpectedToFail({
+ response,
+ errorMessage: 'Impersonate should have failed but did not',
+ });
+ }
+
+ if (expectToFail === false) {
+ warnIfErrorButNotExpectedToFail({
+ response,
+ errorMessage: 'Impersonate has failed but should not',
+ });
+ }
+
+ return { data: response.body.data, errors: response.body.errors };
+};